unifi-protect
Version:
A complete implementation of the UniFi Protect API.
939 lines • 36.7 kB
JavaScript
import { ProtectApi } from "../index.js";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import util from "node:util";
// Recursively search all string values in an object tree, returning the first value that satisfies the predicate. This is used for device association - finding a
// device ID anywhere in an event packet regardless of which field contains it, making the search forward-compatible with new event shapes.
function findStringValue(obj, predicate) {
if (typeof obj === "string") {
return predicate(obj) ? obj : null;
}
if ((obj === null) || (obj === undefined) || (typeof obj !== "object")) {
return null;
}
if (Array.isArray(obj)) {
for (const item of obj) {
const result = findStringValue(item, predicate);
if (result !== null) {
return result;
}
}
return null;
}
for (const value of Object.values(obj)) {
const result = findStringValue(value, predicate);
if (result !== null) {
return result;
}
}
return null;
}
// Resolve a dot-path to a value within an event packet. Paths starting with "payload." or "header." resolve within those sub-objects...all other paths default to
// the header for backward compatibility.
function getValueAtPath(packet, path) {
const parts = path.split(".");
let current = packet;
if (parts[0] === "payload") {
current = packet.payload;
parts.shift();
}
else if (parts[0] === "header") {
current = packet.header;
parts.shift();
}
else {
// Default to header for backward compatibility.
current = packet.header;
}
for (const part of parts) {
if ((current === null) || (current === undefined)) {
return undefined;
}
if (typeof current === "object") {
current = current[part];
}
else {
return undefined;
}
}
return current;
}
// Property filter for matching header or payload properties.
class PropertyFilter {
negate;
operator;
path;
regex;
value;
constructor(path, operator, value) {
this.path = path;
this.operator = operator;
this.value = value;
this.negate = (operator === "!=") || (operator === "!~");
// Check if value is a regex pattern (delimited by /).
if (((operator === "~") || (operator === "!~")) && value.startsWith("/") && (value.lastIndexOf("/") > 0)) {
const lastSlash = value.lastIndexOf("/");
const pattern = value.substring(1, lastSlash);
const flags = value.substring(lastSlash + 1);
try {
this.regex = new RegExp(pattern, flags);
}
catch {
throw new Error("Invalid regular expression: " + value);
}
}
else if ((operator === "~") || (operator === "!~")) {
// Treat as glob pattern - convert to regex.
const regexPattern = value.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
this.regex = new RegExp("^" + regexPattern + "$", "i");
}
else {
this.regex = null;
}
// Validate that the filter value is numeric for comparison operators. The event value varies per packet so we can only validate the user's side at construction.
if ([">", "<", ">=", "<="].includes(operator) && Number.isNaN(Number(value))) {
throw new Error("Numeric comparison operator \"" + operator + "\" requires a numeric value, got: " + value);
}
}
matches(packet, context) {
// Special handling for "device" filter - resolve the device name or ID, then search the entire packet for it. We scan all string values in both the header and
// payload rather than checking specific fields, so that any event shape referencing the device (via id, recordId, camera, cameraId, etc.) is matched
// forward-compatibly.
if (this.path === "device") {
const targetId = context.resolveDeviceName(this.value) ?? this.value;
const found = findStringValue(packet, (value) => value.toLowerCase() === targetId.toLowerCase()) !== null;
return this.negate ? !found : found;
}
// Get the value at the specified path.
const actualValue = getValueAtPath(packet, this.path);
if (actualValue === undefined) {
return this.negate;
}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return this.compareValues(String(actualValue));
}
compareValues(actualValue) {
const normalizedActual = actualValue.toLowerCase();
const normalizedExpected = this.value.toLowerCase();
switch (this.operator) {
case "==":
return normalizedActual === normalizedExpected;
case "!=":
return normalizedActual !== normalizedExpected;
case "~":
return this.regex ? this.regex.test(actualValue) : normalizedActual.includes(normalizedExpected);
case "!~":
return this.regex ? !this.regex.test(actualValue) : !normalizedActual.includes(normalizedExpected);
case ">":
return Number(actualValue) > Number(this.value);
case "<":
return Number(actualValue) < Number(this.value);
case ">=":
return Number(actualValue) >= Number(this.value);
case "<=":
return Number(actualValue) <= Number(this.value);
default:
return false;
}
}
describe() {
const opSymbol = this.operator;
return this.path + " " + opSymbol + " " + this.value;
}
}
// Model key shortcut filter (--cameras, --lights, etc.).
class ModelKeyFilter {
modelKey;
negate;
constructor(modelKey, negate = false) {
this.modelKey = modelKey;
this.negate = negate;
}
matches(packet) {
const matches = packet.header.modelKey.toLowerCase() === this.modelKey.toLowerCase();
return this.negate ? !matches : matches;
}
describe() {
return (this.negate ? "not " : "") + "modelKey=" + this.modelKey;
}
}
// Composite filter for combining multiple filters with AND/OR logic.
class CompositeFilter {
filters;
operator;
constructor(filters, operator = "and") {
this.filters = filters;
this.operator = operator;
}
matches(packet, context) {
if (this.filters.length === 0) {
return true;
}
if (this.operator === "and") {
return this.filters.every(filter => filter.matches(packet, context));
}
return this.filters.some(filter => filter.matches(packet, context));
}
describe() {
const descriptions = this.filters.map(f => f.describe());
return "(" + descriptions.join(" " + this.operator.toUpperCase() + " ") + ")";
}
}
// Motion event filter - filters for events with motion detection.
class MotionFilter {
matches(packet) {
const payload = packet.payload;
if (!payload) {
return false;
}
// Check various motion indicators in the payload.
return Boolean(payload.isMotionDetected) || Boolean(payload.lastMotion) || (payload.type === "motion");
}
describe() {
return "motion events";
}
}
// Ring event filter - filters for doorbell ring events.
class RingFilter {
matches(packet) {
const payload = packet.payload;
if (!payload) {
return false;
}
return Boolean(payload.lastRing) || (payload.type === "ring");
}
describe() {
return "ring events";
}
}
// Smart detection filter - filters for smart detection events (person, vehicle, package, etc.).
class SmartDetectionFilter {
detectionType;
constructor(detectionType = null) {
this.detectionType = detectionType;
}
matches(packet) {
const payload = packet.payload;
if (!payload) {
return false;
}
const smartDetectTypes = payload.smartDetectTypes;
if (!smartDetectTypes || !Array.isArray(smartDetectTypes)) {
return false;
}
if (this.detectionType) {
return smartDetectTypes.some(t => t.toLowerCase() === this.detectionType?.toLowerCase());
}
return smartDetectTypes.length > 0;
}
describe() {
return this.detectionType ? "smart detection: " + this.detectionType : "smart detection events";
}
}
// Default output formatter with colors and full depth.
class DefaultFormatter {
format(packet) {
return util.inspect(packet, { colors: true, depth: null, sorted: true });
}
}
// JSON output formatter - one line per event, no colors.
class JsonFormatter {
format(packet) {
return JSON.stringify(packet);
}
}
// Compact output formatter - header only.
class CompactFormatter {
format(packet, context) {
const header = packet.header;
const deviceId = context.findDeviceId(packet);
const deviceName = deviceId ? (context.getDeviceName(deviceId) ?? deviceId) : header.id;
return "[" + header.modelKey + "] " + deviceName + " - " + header.action;
}
}
// Fields output formatter - specific fields only.
class FieldsFormatter {
fields;
constructor(fields) {
this.fields = fields;
}
format(packet) {
const result = {};
for (const field of this.fields) {
const value = getValueAtPath(packet, field);
if (value !== undefined) {
result[field] = value;
}
}
return JSON.stringify(result);
}
}
// Create a new Protect API instance.
const ufp = new ProtectApi();
// Log utilities.
const log = {
/* eslint-disable no-console */
debug: (message, ...parameters) => {
if (debugEnabled) {
console.error("[DEBUG] " + util.format(message, ...parameters));
}
},
error: (message, ...parameters) => { console.error(util.format(message, ...parameters)); },
info: (message, ...parameters) => { console.log(util.format(message, ...parameters)); },
warn: (message, ...parameters) => { console.log(util.format(message, ...parameters)); }
/* eslint-enable no-console */
};
// Debug mode flag.
let debugEnabled = false;
// Parse command line arguments into structured options.
function parseArguments(argv) {
const options = {
args: [],
command: "",
debug: false,
filters: [],
help: false,
maxEvents: 0,
outputFields: [],
outputFormat: "default",
statsMode: false,
timeout: 0
};
const args = argv.slice(2);
let i = 0;
// Extract the value for a flag that accepts a value in either `--flag=value` or `--flag value` form. Returns the value and advances the index past the consumed
// arguments, or returns null if the current argument doesn't match the flag name. If the flag matches but no value is provided (end of args or next arg is another
// flag), exits with an error message.
const consumeFlagValue = (flag) => {
if (args[i] === flag) {
if (((i + 1) >= args.length) || args[i + 1].startsWith("--")) {
log.error("Missing value for %s.", flag);
process.exit(1);
}
i += 2;
return args[i - 1];
}
if (args[i].startsWith(flag + "=")) {
i++;
return args[i - 1].substring(flag.length + 1);
}
return null;
};
// Model key flag-to-modelKey mapping. Data-driven so adding new device types is a single table entry.
const modelKeyFlags = {
"--cameras": "camera",
"--chimes": "chime",
"--lights": "light",
"--nvr": "nvr",
"--sensors": "sensor",
"--viewers": "viewer"
};
while (i < args.length) {
const arg = args[i];
// Global flags.
if ((arg === "--help") || (arg === "-h")) {
options.help = true;
i++;
continue;
}
if ((arg === "--debug") || (arg === "-d")) {
options.debug = true;
i++;
continue;
}
// Output format flags.
if (arg === "--json") {
options.outputFormat = "json";
i++;
continue;
}
if (arg === "--compact") {
options.outputFormat = "compact";
i++;
continue;
}
const fieldsValue = consumeFlagValue("--fields");
if (fieldsValue !== null) {
options.outputFormat = "default";
options.outputFields = fieldsValue.split(",").map((f) => f.trim());
continue;
}
// Event control flags.
const timeoutValue = consumeFlagValue("--timeout");
if (timeoutValue !== null) {
const parsed = Number(timeoutValue);
if (Number.isNaN(parsed) || (parsed <= 0)) {
log.error("Invalid timeout value: %s.", timeoutValue);
process.exit(1);
}
options.timeout = parsed * 1000;
continue;
}
const countValue = consumeFlagValue("--count");
if (countValue !== null) {
const parsed = Number(countValue);
if (Number.isNaN(parsed) || (parsed <= 0) || !Number.isInteger(parsed)) {
log.error("Invalid count value: %s.", countValue);
process.exit(1);
}
options.maxEvents = parsed;
continue;
}
if (arg === "--stats") {
options.statsMode = true;
i++;
continue;
}
// Model key shortcuts.
if (arg in modelKeyFlags) {
options.filters.push(new ModelKeyFilter(modelKeyFlags[arg]));
i++;
continue;
}
// Special event type filters.
if (arg === "--motion") {
options.filters.push(new MotionFilter());
i++;
continue;
}
if (arg === "--ring") {
options.filters.push(new RingFilter());
i++;
continue;
}
if (arg === "--smart") {
options.filters.push(new SmartDetectionFilter());
i++;
continue;
}
const smartValue = consumeFlagValue("--smart");
if (smartValue !== null) {
options.filters.push(new SmartDetectionFilter(smartValue));
continue;
}
// Device filter shortcut.
const deviceValue = consumeFlagValue("--device");
if (deviceValue !== null) {
options.filters.push(new PropertyFilter("device", "==", deviceValue));
continue;
}
// Property filters in the form property=value, property!=value, property~value, etc.
const filterMatch = /^([a-zA-Z0-9_.]+)(==|!=|~|!~|>=|<=|>|<|=)(.+)$/.exec(arg);
if (filterMatch) {
const [, path, op, value] = filterMatch;
const operator = (op === "=") ? "==" : op;
options.filters.push(new PropertyFilter(path, operator, value));
i++;
continue;
}
// First non-flag argument is the command.
if (!options.command) {
options.command = arg.toLowerCase();
i++;
continue;
}
// Remaining arguments are passed to the command.
options.args.push(arg);
i++;
}
return options;
}
// Create the filter context for resolving device names and accessing bootstrap data.
function createFilterContext() {
const deviceNameMap = new Map();
const deviceIdMap = new Map();
// Build device name to ID mapping from bootstrap.
if (ufp.bootstrap) {
const deviceArrays = [
ufp.bootstrap.cameras,
ufp.bootstrap.chimes,
ufp.bootstrap.lights,
ufp.bootstrap.sensors,
ufp.bootstrap.viewers
];
for (const devices of deviceArrays) {
for (const device of devices) {
// We use || instead of ?? intentionally - we want to skip empty strings, not just null/undefined.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const name = device.name || device.marketName || "";
if (name) {
deviceNameMap.set(name.toLowerCase(), device.id);
deviceIdMap.set(device.id.toLowerCase(), name);
}
}
}
}
return {
bootstrap: ufp.bootstrap,
findDeviceId: (packet) => findStringValue(packet, (value) => deviceIdMap.has(value.toLowerCase())),
getDeviceName: (id) => deviceIdMap.get(id.toLowerCase()) ?? null,
resolveDeviceName: (name) => {
// First check if it's already an ID (case-insensitive).
if (deviceIdMap.has(name.toLowerCase())) {
return name;
}
// Try to find by name (case-insensitive).
return deviceNameMap.get(name.toLowerCase()) ?? null;
}
};
}
// Create the appropriate output formatter based on options.
function createFormatter(options) {
if (options.outputFields.length > 0) {
return new FieldsFormatter(options.outputFields);
}
switch (options.outputFormat) {
case "json":
return new JsonFormatter();
case "compact":
return new CompactFormatter();
default:
return new DefaultFormatter();
}
}
// Bootstrap command - output the controller bootstrap data.
function handleBootstrap(options) {
if (options.help) {
log.error("Usage: ufp bootstrap");
log.error("");
log.error("Output the complete bootstrap data from the Protect controller.");
log.error("");
log.error("Options:");
log.error(" --json Output as JSON (no colors)");
process.exit(0);
}
const output = (options.outputFormat === "json") ? JSON.stringify(ufp.bootstrap) : util.inspect(ufp.bootstrap, { colors: true, depth: null, sorted: true });
process.stdout.write(output + "\n", () => process.exit(0));
}
// Events command - listen for and display realtime events.
function handleEvents(options) {
if (options.help) {
log.error("Usage: ufp events [filters...] [options]");
log.error("");
log.error("Listen for realtime events from the Protect controller.");
log.error("");
log.error("Filter Syntax:");
log.error(" property=value Match property equals value (case-insensitive)");
log.error(" property!=value Match property not equals value");
log.error(" property~pattern Match property contains/matches pattern");
log.error(" property!~pattern Match property does not contain/match pattern");
log.error(" property>/</>=/<=/value Numeric comparisons");
log.error("");
log.error(" Patterns can be:");
log.error(" - Simple text (contains match)");
log.error(" - Glob patterns with * and ? wildcards");
log.error(" - Regex patterns delimited by / (e.g., /^update$/i)");
log.error("");
log.error(" Property paths:");
log.error(" - Header properties: action, id, modelKey, or header.action");
log.error(" - Payload properties: payload.isMotionDetected, payload.lastMotion.start");
log.error("");
log.error("Device Shortcuts (flags that take a value accept both --flag VALUE and --flag=VALUE):");
log.error(" --device NAME Filter by device name (resolved to ID)");
log.error(" --cameras Filter for camera events only");
log.error(" --lights Filter for light events only");
log.error(" --sensors Filter for sensor events only");
log.error(" --chimes Filter for chime events only");
log.error(" --viewers Filter for viewer events only");
log.error(" --nvr Filter for NVR events only");
log.error("");
log.error("Event Type Shortcuts:");
log.error(" --motion Filter for motion detection events");
log.error(" --ring Filter for doorbell ring events");
log.error(" --smart Filter for any smart detection events");
log.error(" --smart TYPE Filter for specific smart detection (person, vehicle, package, animal, etc.)");
log.error("");
log.error("Output Options:");
log.error(" --json Output as JSON (one event per line, no colors)");
log.error(" --compact Output compact format (header summary only)");
log.error(" --fields a,b,c Output specific fields only as JSON");
log.error("");
log.error("Control Options:");
log.error(" --timeout SECONDS Exit after specified seconds");
log.error(" --count N Exit after N events");
log.error(" --stats Show event statistics instead of raw events");
log.error("");
log.error("Examples:");
log.error(" ufp events # All events");
log.error(" ufp events --cameras # Camera events only");
log.error(" ufp events --device \"Front Door\" # Events for specific device");
log.error(" ufp events modelKey=camera action=update # Multiple filters (AND)");
log.error(" ufp events --motion --cameras # Motion events from cameras");
log.error(" ufp events action~\"/^(add|update)$/\" # Regex filter");
log.error(" ufp events --json --count 10 # 10 events as JSON");
process.exit(0);
}
// Legacy support: if we have exactly 2 positional args, treat as property=value filter.
if ((options.args.length === 2) && (options.filters.length === 0)) {
options.filters.push(new PropertyFilter(options.args[0], "==", options.args[1]));
}
const context = createFilterContext();
const formatter = createFormatter(options);
const compositeFilter = new CompositeFilter(options.filters, "and");
const stats = { byAction: {}, byModelKey: {}, total: 0 };
let eventCount = 0;
let timeoutHandle = null;
// Graceful shutdown handler.
const shutdown = () => {
if (options.statsMode) {
log.info("");
log.info("Event Statistics:");
log.info(" Total events: %d", stats.total);
log.info("");
log.info(" By Model Key:");
for (const [key, count] of Object.entries(stats.byModelKey).sort((a, b) => b[1] - a[1])) {
log.info(" %s: %d", key, count);
}
log.info("");
log.info(" By Action:");
for (const [key, count] of Object.entries(stats.byAction).sort((a, b) => b[1] - a[1])) {
log.info(" %s: %d", key, count);
}
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
process.exit(0);
};
// Set up signal handlers for graceful shutdown.
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Set up timeout if specified.
if (options.timeout > 0) {
timeoutHandle = setTimeout(shutdown, options.timeout);
}
// Listen for events.
ufp.on("message", (packet) => {
// Apply filters.
if (!compositeFilter.matches(packet, context)) {
return;
}
eventCount++;
if (options.statsMode) {
// Update statistics.
stats.total++;
stats.byModelKey[packet.header.modelKey] = (stats.byModelKey[packet.header.modelKey] ?? 0) + 1;
stats.byAction[packet.header.action] = (stats.byAction[packet.header.action] ?? 0) + 1;
}
else {
// Output the event.
log.info(formatter.format(packet, context));
}
// Check event count limit.
if ((options.maxEvents > 0) && (eventCount >= options.maxEvents)) {
shutdown();
}
});
log.debug("Listening for events with %d filters.", options.filters.length);
if (options.filters.length > 0) {
log.debug("Filters: %s", compositeFilter.describe());
}
}
// IDR command - set IDR intervals for all cameras.
async function handleIdr(options) {
if (options.help) {
log.error("Usage: ufp idr INTERVAL");
log.error("");
log.error("Set the IDR interval for all non-third-party cameras.");
log.error("");
log.error("Arguments:");
log.error(" INTERVAL IDR interval value (1-5)");
process.exit(0);
}
if (options.args.length !== 1) {
log.error("Usage: ufp idr INTERVAL (1-5)");
process.exit(1);
}
const idrValue = Number(options.args[0]);
if (Number.isNaN(idrValue) || !Number.isInteger(idrValue) || (idrValue < 1) || (idrValue > 5)) {
log.error("IDR interval must be an integer between 1 and 5.");
process.exit(1);
}
for (const device of ufp.bootstrap?.cameras.filter(camera => !camera.isThirdPartyCamera) ?? []) {
// eslint-disable-next-line no-await-in-loop
await ufp.updateDevice(device, { channels: device.channels.map((x) => ({ ...x, idrInterval: idrValue })) });
log.info("%s: IDR set to %d.", ufp.getDeviceName(device), idrValue);
}
process.exit(0);
}
// Known device classes and their corresponding bootstrap property names.
const deviceClasses = ["cameras", "chimes", "lights", "sensors", "viewers"];
// Restart command - restart the console, device classes, or individual devices.
async function handleRestart(options) {
if (options.help) {
log.error("Usage: ufp restart TARGET");
log.error("");
log.error("Restart the console, all devices, a device class, or a specific device.");
log.error("");
log.error("Targets:");
log.error(" console Reboot the UniFi OS console");
log.error(" devices Restart all connected Protect devices");
log.error(" cameras Restart all connected cameras");
log.error(" chimes Restart all connected chimes");
log.error(" lights Restart all connected lights");
log.error(" sensors Restart all connected sensors");
log.error(" viewers Restart all connected viewers");
log.error(" DEVICE_NAME_OR_ID Restart a specific device by name or ID");
process.exit(0);
}
if (options.args.length !== 1) {
log.error("Usage: ufp restart TARGET");
log.error("Run 'ufp restart --help' for more information.");
process.exit(1);
}
if (!ufp.bootstrap) {
log.error("Unable to retrieve the controller bootstrap.");
process.exit(1);
}
const target = options.args[0];
// Reboot the UniFi OS console.
if (target === "console") {
const response = await ufp.retrieve("https://" + ufp.bootstrap.nvr.host + "/api/system/reboot", { method: "POST" });
if (!ufp.responseOk(response?.statusCode)) {
log.error("Unable to reboot the console.");
process.exit(1);
}
log.info("Console is rebooting.");
process.exit(0);
}
// Collect devices from the requested device classes, or match by name/ID if the target isn't a known class.
const classes = (target === "devices") ? [...deviceClasses] : deviceClasses.includes(target) ? [target] : [];
let devices = [];
if (classes.length > 0) {
// Collect all devices from the requested classes.
for (const deviceClass of classes) {
devices.push(...ufp.bootstrap[deviceClass]);
}
}
else {
// Match devices by name or ID across all classes.
for (const deviceClass of deviceClasses) {
for (const device of ufp.bootstrap[deviceClass]) {
if ((device.id === target) || (device.name?.toLowerCase() === target.toLowerCase())) {
devices.push(device);
}
}
}
if (devices.length === 0) {
log.error("No device found matching: %s", target);
listDevices();
process.exit(1);
}
}
// Filter to devices that are connected and not already rebooting.
devices = devices.filter(device => {
if (device.isRebooting) {
log.warn("%s: already rebooting.", ufp.getDeviceName(device));
return false;
}
if (device.state !== "CONNECTED") {
log.warn("%s: not connected.", ufp.getDeviceName(device));
return false;
}
return true;
});
if (devices.length === 0) {
log.info("No connected devices found to restart.");
process.exit(0);
}
for (const device of devices) {
// eslint-disable-next-line no-await-in-loop
const response = await ufp.retrieve(ufp.getApiEndpoint(device.modelKey) + "/" + device.id + "/reboot", { body: JSON.stringify({}), method: "POST" });
if (!ufp.responseOk(response?.statusCode)) {
log.warn("%s: unable to restart.", ufp.getDeviceName(device));
continue;
}
log.info("%s: restarted.", ufp.getDeviceName(device));
}
process.exit(0);
}
// Stream command - stream video from a camera.
async function handleStream(options) {
if (options.help) {
log.error("Usage: ufp stream CAMERA_NAME [CHANNEL_NAME]");
log.error("");
log.error("Stream video from a camera.");
log.error("");
log.error("Arguments:");
log.error(" CAMERA_NAME Name of the camera to stream from");
log.error(" CHANNEL_NAME Optional channel name (defaults to first channel)");
process.exit(0);
}
if ((options.args.length < 1) || (options.args.length > 2)) {
log.error("Usage: ufp stream CAMERA_NAME [CHANNEL_NAME]");
process.exit(1);
}
const cameraName = options.args[0];
const camera = ufp.bootstrap?.cameras.find(cam => cam.name?.toLowerCase() === cameraName.toLowerCase());
if (!camera) {
log.error("Camera not found: %s", cameraName);
listDevices();
process.exit(1);
}
let channel = 0;
if (options.args.length === 2) {
const channelName = options.args[1];
channel = camera.channels.find(ch => ch.name.toLowerCase() === channelName.toLowerCase())?.id;
if (channel === undefined) {
log.error("Channel not found: %s", channelName);
log.error("Available channels: %s", camera.channels.map(ch => ch.name).join(", "));
process.exit(1);
}
}
const ls = ufp.createLivestream();
await ls.start(camera.id, channel, { chunkSize: 16384, useStream: true });
ls.stream?.pipe(process.stdout);
}
// Devices command - list all devices.
function handleDevices(options) {
if (options.help) {
log.error("Usage: ufp devices [TYPE]");
log.error("");
log.error("List all devices or devices of a specific type.");
log.error("");
log.error("Arguments:");
log.error(" TYPE Optional device type (cameras, chimes, lights, sensors, viewers)");
process.exit(0);
}
const deviceType = options.args[0]?.toLowerCase();
if (deviceType && !["cameras", "chimes", "lights", "sensors", "viewers"].includes(deviceType)) {
log.error("Unknown device type: %s", deviceType);
log.error("Valid types: cameras, chimes, lights, sensors, viewers");
process.exit(1);
}
if (options.outputFormat === "json") {
const devices = {};
for (const deviceClass of deviceClasses) {
if (!deviceType || (deviceType === deviceClass)) {
devices[deviceClass] = (ufp.bootstrap?.[deviceClass] ?? []);
}
}
log.info(JSON.stringify(devices));
}
else {
listDevices(deviceType);
}
process.exit(0);
}
// Command registry.
const commands = {
bootstrap: handleBootstrap,
devices: handleDevices,
events: handleEvents,
idr: handleIdr,
restart: handleRestart,
stream: handleStream
};
// List devices helper function.
function listDevices(filterType) {
if (!ufp.bootstrap) {
return;
}
for (const deviceClass of deviceClasses) {
if (filterType && (deviceClass !== filterType)) {
continue;
}
const devices = ufp.bootstrap[deviceClass];
if (devices.length === 0) {
continue;
}
log.error("%s:", deviceClass.charAt(0).toUpperCase() + deviceClass.slice(1));
for (const device of devices) {
log.error(" %s => %s", device.name ?? device.marketName, device.id);
}
}
}
// Show usage information.
function usage() {
log.error("Usage: ufp [--debug] [--help] COMMAND [options]");
log.error("");
log.error("Commands:");
log.error(" bootstrap Output the controller bootstrap data");
log.error(" devices List all devices");
log.error(" events Listen for realtime events");
log.error(" restart Restart the console, devices, or a specific device");
log.error(" stream Stream video from a camera");
log.error("");
log.error("Global Options:");
log.error(" --debug, -d Enable debug output");
log.error(" --help, -h Show help for a command");
log.error("");
log.error("Run 'ufp COMMAND --help' for more information on a command.");
log.error("");
listDevices();
process.exit(1);
}
// Load configuration.
function loadConfig() {
const configFile = ["ufp.json", homedir() + "/.ufp.json"].find(path => existsSync(path));
if (!configFile) {
log.error("No credentials found in ./ufp.json or ~/.ufp.json.");
log.error("Credentials must be in JSON form with properties: controller, username, password");
process.exit(1);
}
let config;
try {
config = JSON.parse(readFileSync(configFile, "utf8"));
}
catch (error) {
log.error("Failed to parse configuration file: %s", configFile);
log.error("Ensure the file contains valid JSON.");
process.exit(1);
}
if (!config.controller || !config.username || !config.password) {
log.error("Configuration file is missing required properties.");
log.error("Required: controller, username, password");
process.exit(1);
}
return config;
}
// Main execution.
async function main() {
const options = parseArguments(process.argv);
debugEnabled = options.debug;
// Show general help if requested without a command.
if (options.help && !options.command) {
usage();
}
// Require a command.
if (!options.command) {
usage();
}
// Check if the command exists.
const handler = commands[options.command];
if (!handler) {
log.error("Unknown command: %s", options.command);
usage();
}
// Load configuration and connect.
const config = loadConfig();
log.debug("Connecting to %s", config.controller);
if (!(await ufp.login(config.controller, config.username, config.password))) {
log.error("Invalid login credentials.");
process.exit(1);
}
log.debug("Logged in successfully.");
if (!(await ufp.getBootstrap())) {
log.error("Unable to bootstrap the Protect controller.");
process.exit(1);
}
log.debug("Bootstrap complete. Found %d cameras.", ufp.bootstrap?.cameras.length ?? 0);
// Silently exit on pipe errors but still process other errors.
process.stdout.on("error", (err) => {
if (err.code === "EPIPE") {
process.exit(0);
}
else {
throw err;
}
});
// Execute the command.
await handler(options);
}
// Run main.
main().catch((error) => {
log.error("Fatal error: %s", error);
process.exit(1);
});
//# sourceMappingURL=ufp.js.map