UNPKG

unifi-protect

Version:

A complete implementation of the UniFi Protect API.

939 lines 36.7 kB
#!/usr/bin/env node 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