UNPKG

unifi-protect

Version:

A complete implementation of the UniFi Protect API.

1,157 lines 75.9 kB
/* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved. * * protect-api.ts: Our UniFi Protect API implementation. */ /** * A complete implementation of the UniFi Protect API, providing comprehensive access to UniFi Protect controllers. * * ## Overview * * This module provides a high-performance, event-driven interface to the UniFi Protect API, enabling full access to * Protect's rich ecosystem of security devices and capabilities. The API has been reverse-engineered through careful * analysis of the Protect web interface and extensive testing, as Ubiquiti does not provide official documentation. * * ## Key Features * * - **Complete Device Support**: Cameras, lights, sensors, chimes, viewers, and the NVR itself * - **Real-time Events**: WebSocket-based event streaming for instant notifications * - **Livestream Access**: Direct H.264 fMP4 stream access, not just RTSP * - **Robust Error Handling**: Automatic retry logic with exponential backoff * - **Type Safety**: Full TypeScript support with comprehensive type definitions * * ## Quick Start * * ```typescript * import { ProtectApi } from "unifi-protect"; * * // Create an API instance. * const protect = new ProtectApi(); * * // Login to your Protect controller. * await protect.login("192.168.1.1", "username", "password"); * * // Bootstrap to get the current state. * await protect.getBootstrap(); * * // Access your devices. * const cameras = protect.bootstrap?.cameras ?? []; * console.log("Found " + cameras.length.toString() + " cameras."); * * // Listen for real-time events. * protect.on("message", (packet) => { * console.log("Event received:", packet); * }); * ``` * * ## Architecture * * The API is built on modern Node.js technologies: * - **Undici**: High-performance HTTP/1.1 and HTTP/2 client * - **WebSockets**: Real-time bidirectional communication * - **EventEmitter**: Node.js event-driven architecture * * ## Authentication * * The API uses cookie-based authentication with CSRF token protection, mimicking the Protect web interface. * Administrative privileges are required for configuration changes, while read-only access is available to all users. * * @module ProtectApi */ import { Agent, Pool, WebSocket, errors, interceptors, request } from "undici"; import { PROTECT_API_ERROR_LIMIT, PROTECT_API_RETRY_INTERVAL, PROTECT_API_TIMEOUT } from "./settings.js"; import { decodePacket } from "./protect-api-events.js"; import { EventEmitter } from "node:events"; import { ProtectLivestream } from "./protect-api-livestream.js"; import { STATUS_CODES } from "node:http"; import util from "node:util"; // Protect controller status codes that indicate transient server-side issues. These should be kept in sync with the Undici retry interceptor's statusCodes list in // reset(), which retries on the same set of codes before _retrieve ever sees them. // // 400: Bad request. // 404: Not found. // 429: Too many requests. // 500: Internal server error. // 502: Bad gateway. // 503: Service temporarily unavailable. // 504: Gateway timeout. const PROTECT_SERVER_ERRORS = new Set([400, 404, 429, 500, 502, 503, 504]); /** * This class provides an event-driven API to access the UniFi Protect API. * * ## Getting Started * * To begin using the API, follow these three essential steps: * * 1. **Login**: Authenticate with the Protect controller using {@link login} * 2. **Bootstrap**: Retrieve the controller configuration with {@link getBootstrap} * 3. **Listen**: Subscribe to real-time events via the `message` event * * ## Events * * The API emits several events during its lifecycle: * * | Event | Payload | Description | * |-------|---------|-------------| * | `login` | `boolean` | Emitted after each login attempt with success status | * | `bootstrap` | {@link ProtectNvrBootstrap} | Emitted when bootstrap data is retrieved | * | `message` | {@link ProtectEventPacket} | Real-time event packets from the controller | * * ## Connection Management * * The API automatically manages connection pooling and implements intelligent retry logic: * - Up to 5 concurrent connections * - Automatic retry with exponential backoff * - Throttling after repeated failures * - Graceful WebSocket reconnection * * ## Error Handling * * All API methods implement comprehensive error handling: * - Network errors are logged and retried * - Authentication failures trigger re-login attempts * - Server errors are handled gracefully * - Detailed logging for debugging * * @event login - Emitted after each login attempt with the success status as a boolean value. This event fires whether the login succeeds or fails, allowing * applications to respond appropriately to authentication state changes. * @event bootstrap - Emitted when bootstrap data is successfully retrieved from the controller. The event includes the complete {@link ProtectNvrBootstrap} * configuration object containing all device states and system settings. * @event message - Emitted for each real-time event packet received from the controller's WebSocket connection. The event includes a * {@link ProtectEventPacket} containing device updates, motion events, and other system notifications. * * @example * Complete example with error handling and event processing: * * ```typescript * import { ProtectApi, ProtectCameraConfig } from "unifi-protect"; * * class ProtectManager { * private api: ProtectApi; * * constructor() { * this.api = new ProtectApi(); * this.setupEventHandlers(); * } * * private setupEventHandlers(): void { * // Handle login events. * this.api.on("login", (success: boolean) => { * console.log(success ? "Login successful." : "Login failed."); * }); * * // Process real-time events. * this.api.on("message", (packet) => { * if(packet.header.modelKey === "camera") { * console.log("Camera event:", packet); * } * }); * } * * async connect(host: string, username: string, password: string): Promise<boolean> { * try { * // Login to the controller. * if(!await this.api.login(host, username, password)) { * throw new Error("Authentication failed."); * } * * // Bootstrap the configuration. * if(!await this.api.getBootstrap()) { * throw new Error("Bootstrap failed."); * } * * console.log("Connected to " + this.api.name + "."); * return true; * * } catch(error) { * console.error("Connection failed:", error); * return false; * } * } * * async enableAllRtspStreams(): Promise<void> { * const cameras = this.api.bootstrap?.cameras ?? []; * * for(const camera of cameras) { * const updated = await this.api.enableRtsp(camera); * if(updated) { * console.log("RTSP enabled for " + this.api.getDeviceName(camera) + "."); * } * } * } * } * ``` */ export class ProtectApi extends EventEmitter { _bootstrap; _eventsWs; apiErrorCount; apiThrottleStart; dispatcher; headers; _isAdminUser; _isThrottled; log; nvrAddress; password; username; wsAgent; /** * Create an instance of the UniFi Protect API. * * @param log - Custom logging implementation. * * @defaultValue Console logging to stdout/stderr * * @remarks * The logging interface allows you to integrate the API with your application's logging system. * By default, errors and warnings are logged to the console, while debug messages are suppressed. * * @example * Using a custom logger: * * ```typescript * import { ProtectApi, ProtectLogging } from "unifi-protect"; * import winston from "winston"; * * const logger = winston.createLogger({ * level: "info", * format: winston.format.simple(), * transports: [new winston.transports.Console()] * }); * * const customLog: ProtectLogging = { * debug: (message: string, ...args: unknown[]) => logger.debug(message, args), * error: (message: string, ...args: unknown[]) => logger.error(message, args), * info: (message: string, ...args: unknown[]) => logger.info(message, args), * warn: (message: string, ...args: unknown[]) => logger.warn(message, args) * }; * * const protect = new ProtectApi(customLog); * ``` * * @category Constructor */ constructor(log) { // Initialize our parent. super(); // If we didn't get passed a logging parameter, by default we log to the console. log ??= { /* eslint-disable no-console */ debug: () => { }, error: (message, ...parameters) => { console.error(message, ...parameters); }, info: (message, ...parameters) => { console.log(message, ...parameters); }, warn: (message, ...parameters) => { console.log(message, ...parameters); } /* eslint-enable no-console */ }; this._bootstrap = null; this._eventsWs = null; this._isAdminUser = false; this._isThrottled = false; this.log = { debug: (message, ...parameters) => { log.debug(this.name + ": " + message, ...parameters); }, error: (message, ...parameters) => { log.error(this.name + ": API error: " + message, ...parameters); }, info: (message, ...parameters) => { log.info(this.name + ": " + message, ...parameters); }, warn: (message, ...parameters) => { log.warn(this.name + ": " + message, ...parameters); } }; this.apiErrorCount = 0; this.apiThrottleStart = 0; this.headers = {}; this.nvrAddress = ""; this.password = ""; this.username = ""; this.wsAgent = null; } /** * Execute a login attempt to the UniFi Protect API. * * @param nvrAddress - Address of the UniFi Protect controller (FQDN or IP address) * @param username - Username for authentication * @param password - Password for authentication * * @returns Promise resolving to `true` on success, `false` on failure. * * @event login - Emitted with `true` if authentication succeeds, `false` if it fails. The event fires after every login attempt, regardless of outcome. * * @remarks * This method performs the following actions: * * - Terminates any existing sessions * - Acquires CSRF tokens for API security * - Establishes cookie-based authentication * - Emits a `login` event with the result * * The method automatically handles UniFi OS CSRF protection and maintains session state for subsequent API calls. Administrative privileges are determined during * login and cached for the session duration. * * @example * Multiple authentication patterns: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * // Pattern 1: Using async/await. * async function connectWithAwait() { * const success = await protect.login("192.168.1.1", "admin", "password"); * if(success) { * console.log("Connected successfully."); * } * } * * // Pattern 2: Using event listeners. * function connectWithEvents() { * protect.once("login", (success: boolean) => { * if(success) { * console.log("Connected successfully."); * // Continue with bootstrap. * protect.getBootstrap(); * } * }); * * protect.login("192.168.1.1", "admin", "password"); * } * * // Pattern 3: With retry logic. * async function connectWithRetry(maxAttempts = 3) { * for(let i = 0; i < maxAttempts; i++) { * if(await protect.login("192.168.1.1", "admin", "password")) { * return true; * } * console.log("Login attempt " + (i + 1).toString() + " failed, retrying..."); * await new Promise(resolve => setTimeout(resolve, 2000)); * } * return false; * } * ``` * * @category Authentication */ async login(nvrAddress, username, password) { this.nvrAddress = nvrAddress; this.username = username; this.password = password; this.logout(); // Let's attempt to login. const loginSuccess = await this.loginController(); // Publish the result to our listeners this.emit("login", loginSuccess); // Return the status of our login attempt. return loginSuccess; } // Login to the UniFi Protect API. async loginController() { // If we're already logged in, we're done. if (this.headers.cookie && this.headers["x-csrf-token"]) { return true; } // Utility to grab the headers we're interested in a normalized manner. const getHeader = (name, headers) => { const rawHeader = headers?.[name.toLowerCase()]; if (!rawHeader) { return null; } // Normalize it to a string: return Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; }; // Attempt to log in directly. If we already have a CSRF token (from a prior session or a previous login attempt), we skip the CSRF pre-fetch entirely and go // straight to the login endpoint. The login response provides an updated CSRF token, so the pre-fetch is only needed if we have no token at all and the controller // rejects our login without one. const loginBody = JSON.stringify({ password: this.password, rememberMe: true, token: "", username: this.username }); let response = await this.retrieve(this.getApiEndpoint("login"), { body: loginBody, method: "POST" }); // If the login failed and we don't have a CSRF token, acquire one and retry. UniFi OS has cross-site request forgery protection built into its web management UI. // Some controllers require a valid CSRF token on the login request itself. if (!this.responseOk(response?.statusCode) && !this.headers["x-csrf-token"]) { // Consume the failed response body so the underlying pool connection can be reused. await response?.body.dump(); const csrfResponse = await this.retrieve("https://" + this.nvrAddress, { method: "GET" }, { logErrors: false }); if (this.responseOk(csrfResponse?.statusCode)) { const csrfToken = getHeader("X-CSRF-Token", csrfResponse?.headers); // Preserve the CSRF token, if found, and retry the login. if (csrfToken) { this.headers["x-csrf-token"] = csrfToken; response = await this.retrieve(this.getApiEndpoint("login"), { body: loginBody, method: "POST" }); } } } // Something went wrong with the login call, possibly a controller reboot or failure. if (!this.responseOk(response?.statusCode)) { this.logout(); return false; } // We're logged in. Let's configure our headers. const csrfToken = getHeader("X-Updated-CSRF-Token", response?.headers) ?? getHeader("X-CSRF-Token", response?.headers); const cookie = getHeader("Set-Cookie", response?.headers); // Save the refreshed cookie and CSRF token for future API calls and we're done. if (csrfToken && cookie) { // Only preserve the token element of the cookie and not the superfluous information that's been added to it. this.headers.cookie = cookie.split(";")[0]; // Save the CSRF token. this.headers["x-csrf-token"] = csrfToken; return true; } // Clear out our login credentials. this.logout(); return false; } // Attempt to retrieve the bootstrap configuration from the Protect NVR. async bootstrapController(retry) { // Log us in if needed. if (!(await this.loginController())) { return retry ? this.bootstrapController(false) : false; } const response = await this.retrieve(this.getApiEndpoint("bootstrap")); // Something went wrong. Retry the bootstrap attempt once, and then we're done. if (!this.responseOk(response?.statusCode)) { this.logRetry("Unable to retrieve the UniFi Protect controller configuration.", retry); return retry ? this.bootstrapController(false) : false; } // Now let's get our NVR configuration information. let data = null; try { data = await response?.body.json(); } catch (error) { this.log.error("Unable to parse response from UniFi Protect. Will retry again later."); return retry ? this.bootstrapController(false) : false; } // Is this the first time we're bootstrapping? const isFirstRun = !this._bootstrap; // Set the new bootstrap. this._bootstrap = data; // Check for admin user privileges or role changes. this.checkAdminUserStatus(isFirstRun); // We're good. Now connect to the event listener API. if (!(await this.launchEventsWs())) { return retry ? this.bootstrapController(false) : false; } // Notify our users. this.emit("bootstrap", this._bootstrap); // We're bootstrapped and connected to the events API. return true; } /** * Connect to the realtime update events API. * * @event message - Emitted for each WebSocket message received from the controller after successful decoding. Each message contains a * {@link ProtectEventPacket} with real-time device updates and system events. * * @internal */ async launchEventsWs() { // Log us in if needed. if (!(await this.loginController())) { return false; } // If we already have a listener, we're already all set. if (this._eventsWs) { return true; } // Launch the realtime events WebSocket. We need to hand it the last update ID we know about in order // to ensure we don't miss any actual updates since we last pulled the bootstrap configuration. const params = new URLSearchParams({ lastUpdateId: this._bootstrap?.lastUpdateId ?? "" }); try { // Let's open the WebSocket connection using the shared WebSocket agent for TLS session reuse and connection efficiency. const ws = new WebSocket("wss://" + this.nvrAddress + "/proxy/protect/ws/updates?" + params.toString(), { dispatcher: this.wsAgent ?? undefined, headers: { Cookie: this.headers.cookie ?? "" } }); let messageHandler; // Handle any WebSocket errors. A single { once: true } handler covers both the connection phase and the post-connection lifetime...the first error on the WebSocket // triggers logging, closes the connection, and the close event handles cleanup. ws.addEventListener("error", (event) => { // Check if this is a TypeError from undici's internal WebSocket handling. They're expected in certain disconnection scenarios. if (!(event.error instanceof TypeError)) { this.log.error("Events API error: %s", event.error.cause); this.log.error(util.inspect(event.error, { colors: true, depth: null, sorted: true })); } ws.close(); }, { once: true }); // Wait for the WebSocket to actually connect before reporting success. This ensures bootstrapController() only signals success when both the HTTP bootstrap and // the realtime events channel are fully established. We use named handlers so that whichever fires first can remove the other, preventing stale listeners from // interfering with post-connection event handling. const connected = await new Promise((resolve) => { const onOpen = () => { ws.removeEventListener("close", onClose); // Make the WebSocket available. this._eventsWs = ws; resolve(true); }; // If the connection fails, the error handler above will close the WebSocket. We listen for close to detect that the connection was never established. const onClose = () => { ws.removeEventListener("open", onOpen); resolve(false); }; ws.addEventListener("open", onOpen, { once: true }); ws.addEventListener("close", onClose, { once: true }); }); // The WebSocket connection failed to establish. if (!connected) { return false; } // Cleanup after ourselves if our WebSocket closes for some reason. ws.addEventListener("close", () => { this._eventsWs = null; if (messageHandler) { ws.removeEventListener("message", messageHandler); messageHandler = null; } }, { once: true }); // Emit queue for ordered event delivery. Packet decoding is async (zlib inflate runs on the libuv threadpool), so multiple packets can be inflating // concurrently. We use .then() here deliberately - it's the right primitive for this pattern. Each message handler starts its decode immediately (parallel // inflate), then chains the emit onto the queue so packets are always emitted in arrival order. We can't use async/await for the chaining because // addEventListener doesn't await handlers, and we want decodes to start immediately rather than waiting for prior packets to complete. let emitQueue = Promise.resolve(); // Chain a decoded packet onto the emit queue. The .then() ensures packets are emitted in arrival order even if later packets finish inflating before earlier // ones. The .catch() prevents a single decode failure from poisoning the queue - without it, a rejected promise would cause all subsequent .then() calls to // also reject. const enqueuePacket = (decoded) => { emitQueue = emitQueue.then(async () => { const packet = await decoded; if (!packet) { this.log.error("Unable to process message from the realtime update events API."); ws.close(); return; } this.emit("message", packet); }).catch((error) => { this.log.error("Error processing events WebSocket message: %s.", error); ws.close(); }); }; // Process messages as they come in. ws.addEventListener("message", messageHandler = (event) => { // Normalize event.data into a Buffer synchronously so we can start decoding immediately. This must happen before we yield to the event loop to preserve the // relationship between arrival order and decode initiation. The type check order matches the original: Blob, ArrayBuffer, string. let buffer; try { if (event.data instanceof Blob) { // Blob.arrayBuffer() is async, so we need to handle this path through the emit queue to maintain ordering. enqueuePacket(event.data.arrayBuffer().then(async (ab) => decodePacket(this.log, Buffer.from(ab)))); return; } else if (event.data instanceof ArrayBuffer) { buffer = Buffer.from(event.data); } else if (typeof event.data === "string") { buffer = Buffer.from(event.data); } else { this.log.error("Unsupported WebSocket message type: %s.", typeof event.data); ws.close(); return; } } catch (error) { this.log.error("Error processing events WebSocket message: %s.", error); ws.close(); return; } // Start decoding immediately. The inflate runs on the libuv threadpool in parallel with any other in-flight decodes. enqueuePacket(decodePacket(this.log, buffer)); }); } catch (error) { this.log.error("Error connecting to the realtime update events API: %s.", error); return false; } return true; } /** * Retrieve the bootstrap JSON from a UniFi Protect controller. * * @returns Promise resolving to `true` on success, `false` on failure. * * @event bootstrap - Emitted with the complete {@link ProtectNvrBootstrap} configuration when successfully retrieved. The bootstrap contains all device configurations, * user accounts, system settings, and current device states. * @event message - Once the bootstrap is retrieved, the WebSocket connection is established and this event will be emitted for each real-time update packet received * from the controller. * * @remarks * The bootstrap contains the complete state of the Protect controller, including: * * - All device configurations (cameras, lights, sensors, etc.) * - User accounts and permissions * - System settings and capabilities * - Current device states and health * * This method automatically: * * - Reconnects if the session has expired * - Establishes WebSocket connections for real-time events * - Determines administrative privileges * - Emits a `bootstrap` event with the configuration * * @example * Working with bootstrap data: * * ```typescript * import { ProtectApi, ProtectCameraConfig } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function analyzeSystem() { * // Login and bootstrap. * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * // Access the bootstrap data. * const bootstrap = protect.bootstrap; * if(!bootstrap) return; * * // System information. * console.log("NVR: " + bootstrap.nvr.name); * console.log("Version: " + bootstrap.nvr.version); * console.log("Up since: " + new Date(bootstrap.nvr.upSince).toString()); * * // Device inventory. * console.log("Cameras: " + bootstrap.cameras.length.toString()); * console.log("Lights: " + bootstrap.lights.length.toString()); * console.log("Sensors: " + bootstrap.sensors.length.toString()); * * // Find specific devices. * const doorbells = bootstrap.cameras.filter((cam) => cam.featureFlags.isDoorbell); * const motionSensors = bootstrap.sensors.filter((sensor) => sensor.type === "motion"); * * // Check recording status. * const recording = bootstrap.cameras.filter((cam) => cam.isRecording && cam.isConnected); * * console.log(recording.length.toString() + " cameras actively recording."); * } * ``` * * @category API Access */ async getBootstrap() { // Bootstrap the controller, and attempt to retry the bootstrap if it fails. return this.bootstrapController(true); } // Check admin privileges. checkAdminUserStatus(isFirstRun = false) { // Get the properties we care about from the bootstrap. const { users, authUserId } = this._bootstrap ?? {}; // Find this user, if it exists. const user = users?.find((x) => x.id === authUserId); // User doesn't exist, we're done. if (!user?.allPermissions) { return false; } // Save our prior state so we can detect role changes without having to restart. const oldAdminStatus = this.isAdminUser; // Determine if the user has administrative camera permissions. this._isAdminUser = user.allPermissions.some(entry => entry.startsWith("camera:") && entry.split(":")[1].split(",").includes("write")); // Only admin users can change certain settings. Inform the user on startup, or if we detect a role change. if (isFirstRun && !this.isAdminUser) { this.log.info("User '%s' requires the Super Admin role in order to change certain settings like camera RTSP stream availability.", this.username); } else if (!isFirstRun && (oldAdminStatus !== this.isAdminUser)) { this.log.info("Role change detected for user '%s': the Super Admin role has been %s.", this.username, this.isAdminUser ? "enabled" : "disabled"); } return true; } /** * Retrieve a snapshot image from a Protect camera. * * @param device - Protect camera device * @param options - Snapshot configuration options * * @returns Promise resolving to a Buffer containing the JPEG image, or `null` on failure. * * @remarks * Snapshots are generated on-demand by the Protect controller. The image quality and resolution depend on the camera's capabilities and current settings. Package * camera snapshots are only available on devices with dual cameras (e.g., G4 Doorbell Pro). * * The `options` parameter accepts: * * | Property | Type | Description | Default | * |----------|------|-------------|---------| * | `width` | `number` | Requested image width in pixels | Camera default | * | `height` | `number` | Requested image height in pixels | Camera default | * | `usePackageCamera` | `boolean` | Use package camera if available | `false` | * * @example * Capturing and saving snapshots: * * ```typescript * import { ProtectApi } from "unifi-protect"; * import { writeFile } from "fs/promises"; * * const protect = new ProtectApi(); * * async function captureSnapshots() { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * const cameras = protect.bootstrap?.cameras ?? []; * * for(const camera of cameras) { * // Full resolution snapshot. * const fullRes = await protect.getSnapshot(camera); * * // Thumbnail snapshot. * const thumbnail = await protect.getSnapshot(camera, { * height: 360, * width: 640 * }); * * // Package camera snapshot (if available). * if(camera.featureFlags.hasPackageCamera) { * const packageSnap = await protect.getSnapshot(camera, { * usePackageCamera: true * }); * * if(packageSnap) { * await writeFile(camera.name + "-package.jpg", packageSnap); * } * } * * if(fullRes && thumbnail) { * await writeFile(camera.name + "-full.jpg", fullRes); * await writeFile(camera.name + "-thumb.jpg", thumbnail); * console.log("Saved snapshots for " + camera.name + "."); * } * } * } * ``` * * @category API Access */ async getSnapshot(device, options = {}) { // We're requesting a package camera snapshot on a camera without one - we're done. if (options.usePackageCamera && !device.featureFlags.hasPackageCamera) { return null; } // Create the parameters needed for the snapshot request. const params = new URLSearchParams(); // If we have details of the snapshot request, use it to request the right size. if (options.height !== undefined) { params.append("h", options.height.toString()); } if (options.width !== undefined) { params.append("w", options.width.toString()); } // Request the image from the controller. const response = await this.retrieve(this.getApiEndpoint(device.modelKey) + "/" + device.id + "/" + (options.usePackageCamera ? "package-" : "") + "snapshot?" + params.toString(), { method: "GET" }); if (!response || !this.responseOk(response.statusCode)) { this.log.error("%s: Unable to retrieve the snapshot.", this.getFullName(device)); return null; } let snapshot; try { snapshot = Buffer.from(await response.body.arrayBuffer()); } catch (error) { this.log.error("%s: Error retrieving the snapshot image: %s", this.getFullName(device), error); return null; } return snapshot; } /** * Update a Protect device's configuration on the UniFi Protect controller. * * @typeParam DeviceType - Generic for any known Protect device type * * @param device - Protect device to update * @param payload - Configuration changes to apply * * @returns Promise resolving to the updated device configuration, or `null` on failure. * * @remarks * This method applies configuration changes to any Protect device. Common modifications include: * * - Camera settings (name, recording modes, motion zones) * - Light settings (brightness, motion activation) * - Sensor settings (sensitivity, mount type) * - Chime settings (volume, ringtones) * * **Important**: Most configuration changes require administrative privileges. The user account must have the Super Admin role assigned in UniFi Protect. * * Changes are applied immediately and persist across device reboots. The method returns the complete updated device configuration, reflecting any server-side * adjustments. * * @example * Common device configuration scenarios: * * ```typescript * import { ProtectApi, ProtectCameraConfig } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function configureDevices() { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * const camera = protect.bootstrap?.cameras[0]; * if(!camera) return; * * // Update camera name and recording settings. * const updatedCamera = await protect.updateDevice(camera, { * name: "Front Door Camera", * recordingSettings: { * mode: "always", * postPaddingSecs: 3, * prePaddingSecs: 3, * retentionDurationMs: 7 * 24 * 60 * 60 * 1000 * } * }); * * // Configure smart detection. * await protect.updateDevice(camera, { * smartDetectSettings: { * autoTrackingObjectTypes: ["person"], * objectTypes: [ "person", "vehicle" ] * } * }); * * // Update light device. * const light = protect.bootstrap?.lights[0]; * if(light) { * await protect.updateDevice(light, { * lightDeviceSettings: { * ledLevel: 6, * pirDuration: 15000, * pirSensitivity: 50 * }, * lightModeSettings: { * mode: "motion" * } * }); * } * * // Configure doorbell chime volume. * const chime = protect.bootstrap?.chimes[0]; * if(chime) { * await protect.updateDevice(chime, { * volume: 75 * }); * } * } * ``` * * @category API Access */ async updateDevice(device, payload) { // Log us in if needed. if (!(await this.loginController())) { return null; } // Only admin users can update JSON objects. if (!this.isAdminUser) { return null; } this.log.debug("%s: %s", this.getFullName(device), util.inspect(payload, { colors: true, depth: null, sorted: true })); // Update Protect with the new configuration. const response = await this.retrieve(this.getApiEndpoint(device.modelKey) + (device.modelKey === "nvr" ? "" : "/" + device.id), { body: JSON.stringify(payload), method: "PATCH" }); // Something happened - the retrieve call will log the error for us. if (!response) { return null; } if (!this.responseOk(response.statusCode)) { this.log.error("%s: Unable to configure the %s: %s.", this.getFullName(device), device.modelKey, response.statusCode); return null; } // We successfully updated the device configuration, return the updated device object. try { return await response.body.json(); } catch (error) { this.log.error("%s: Unable to parse the response from the Protect controller.", this.getFullName(device)); return null; } } // Update camera channels on a supported Protect device. async updateCameraChannels(device, channels) { // Make sure we have the permissions to modify the camera JSON. if (!(await this.canModifyCamera())) { return null; } // Update Protect with the new configuration. const response = await this._retrieve(this.getApiEndpoint(device.modelKey) + "/" + device.id, { body: JSON.stringify({ channels }), method: "PATCH" }, { decodeResponse: false }); // Since we took responsibility for interpreting the outcome of the fetch, we need to check for any errors. if (!response || !this.responseOk(response.statusCode)) { this.apiErrorCount++; if (response?.statusCode === 403) { this.log.error("%s: Insufficient privileges to enable RTSP on all channels. Please ensure this username has the Administrator role assigned in UniFi Protect.", this.getFullName(device)); } else { this.log.error("%s: Unable to enable RTSP on all channels: %s.", this.getFullName(device), response?.statusCode); } // We still return our camera object if there is at least one RTSP channel enabled. return device; } // Since we have taken responsibility for decoding response types, we need to reset our API backoff count. this.apiErrorCount = 0; // Everything worked, save the new channel array. try { return await response.body.json(); } catch (error) { this.log.error("%s: Unable to parse the response from the Protect controller.", this.getFullName(device)); return device; } } /** * Utility method that enables all RTSP channels on a given Protect camera. * * @param device - Protect camera to modify * * @returns Promise resolving to the updated camera configuration, or `null` on failure. * * @remarks * RTSP (Real Time Streaming Protocol) streams allow third-party applications to access camera feeds directly. This method enables RTSP on all available * channels (resolutions) for a camera, making them accessible at: * * - High: `rtsp://[NVR_IP]:7447/[CAMERA_GUID]_0` * - Medium: `rtsp://[NVR_IP]:7447/[CAMERA_GUID]_1` * - Low: `rtsp://[NVR_IP]:7447/[CAMERA_GUID]_2` * * **Note**: Enabling RTSP requires Super Admin privileges in UniFi Protect. * * @example * Enabling RTSP streams for integration: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function setupRtspStreams() { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * const cameras = protect.bootstrap?.cameras ?? []; * * for(const camera of cameras) { * const updated = await protect.enableRtsp(camera); * * if(updated) { * console.log("RTSP enabled for " + camera.name + "."); * * // Display RTSP URLs. * for(const [index, channel] of updated.channels.entries()) { * if(channel.isRtspEnabled) { * const rtspUrl = "rtsp://192.168.1.1:7447/" + channel.rtspAlias; * console.log(" Channel " + index.toString() + ": " + rtspUrl); * } * } * } else { * console.log("Failed to enable RTSP for " + camera.name + "."); * } * } * } * ``` * * @category Utilities */ async enableRtsp(device) { // Make sure we have the permissions to modify the camera JSON. if (!(await this.canModifyCamera())) { return null; } // Do we have any non-RTSP enabled channels? If not, we're done. if (!device.channels.some((channel) => !channel.isRtspEnabled)) { return device; } // Build a new channels array with RTSP enabled on every channel, leaving the caller's device object untouched. The controller's response from the PATCH is the // authoritative state. const channels = device.channels.map((channel) => ({ ...channel, isRtspEnabled: true })); // Update the camera channel JSON with our edits. return this.updateCameraChannels(device, channels); } /** * Utility method that generates a nicely formatted device information string. * * @param device - Protect device * @param name - Custom name to use (defaults to device name) * @param deviceInfo - Include IP and MAC address information * * @returns Formatted device string. * * @remarks * Returns device information in a consistent, readable format: * * - Basic: `Device Name [Device Type]` * - With info: `Device Name [Device Type] (address: IP mac: MAC)` * * This method handles all Protect device types and gracefully handles missing information. * * @example * Formatting device information: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function listDevices() { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * // List all devices with full information. * const allDevices = [ * ...(protect.bootstrap?.cameras ?? []), * ...(protect.bootstrap?.lights ?? []), * ...(protect.bootstrap?.sensors ?? []), * ...(protect.bootstrap?.chimes ?? []), * ...(protect.bootstrap?.viewers ?? []) * ]; * * for(const device of allDevices) { * // Basic format. * console.log(protect.getDeviceName(device)); * // Output: "Front Door [G4 Doorbell Pro]" * * // With network info. * console.log(protect.getDeviceName(device, device.name, true)); * // Output: "Front Door [G4 Doorbell Pro] (address: 192.168.1.50 mac: 00:00:00:00:00:00)" * * // Custom name. * console.log(protect.getDeviceName(device, "Custom Name")); * // Output: "Custom Name [G4 Doorbell Pro]" * } * } * ``` * * @category Utilities */ getDeviceName(device, name = device.name, deviceInfo = false) { // Include the host address information, if we have it. const host = (("host" in device) && device.host) ? "address: " + device.host + " " : ""; const type = (("marketName" in device) && device.marketName) ? device.marketName : device.type; // A completely enumerated device will appear as: // Device Name [Device Type] (address: IP address, mac: MAC address). return (name ?? type) + " [" + type + "]" + (deviceInfo ? " (" + host + "mac: " + device.mac + ")" : ""); } /** * Utility method that generates a combined device and controller information string. * * @param device - Protect device * * @returns Formatted string including both controller and device information. * * @remarks * Combines controller and device information for complete context: `Controller Name [Controller Type] Device Name [Device Type]` * * Useful for logging and multi-controller environments where device context is important. * * @example * Logging with full context: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function monitorDevices() { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * protect.on("message", (packet) => { * const device = protect.bootstrap?.cameras.find((c) => c.id === packet.header.id); * * if(device) { * // Logs: "Dream Machine Pro [UDMP] Front Door [G4 Doorbell Pro]" * console.log(protect.getFullName(device) + ": " + packet.header.action); * } * }); * } * ``` * * @category Utilities */ getFullName(device) { const deviceName = this.getDeviceName(device); // Returns: NVR [NVR Type] Device Name [Device Type] return this.name + (deviceName.length ? " " + deviceName : ""); } /** * Terminate any open connection to the UniFi Protect API. * * @remarks * Performs a clean shutdown of all API connections: * * - Closes WebSocket connections * - Destroys the HTTP connection pool * - Clears cached bootstrap data * - Resets authentication state * * Call this method when shutting down your application or switching controllers. The API can be reused after reset by calling {@link login} again. * * @example * Proper cleanup: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function main() { * try { * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * * // Do work... * * } finally { * // Always clean up connections. * protect.reset(); * } * } * * // Handle process termination. * process.on("SIGINT", () => { * protect.reset(); * process.exit(0); * }); * ``` * * @category Utilities */ reset() { this._bootstrap = null; this._eventsWs?.close(); this._eventsWs = null; // Tear down the shared WebSocket agent. void this.wsAgent?.destroy(); this.wsAgent = null; if (this.nvrAddress) { // Cleanup any prior pool. void this.dispatcher?.destroy(); // Create an interceptor that allows us to set the user agent to our liking. const ua = (dispatch) => (opts, handler) => { opts.headers ??= {}; opts.headers["user-agent"] = "unifi-protect"; return dispatch(opts, handler); }; // Create a dispatcher using a new pool. We want to explicitly allow self-signed SSL certificates, enabled HTTP2 connections, and allow up to five connections at a // time and provide some robust retry handling. We allow for up to five retries, with a maximum wait time of 1500ms per retry, in factors of 2 starting from a 100ms // delay. PATCH is deliberately excluded from the retry list...the Protect API isn't documented and we don't trust that PATCH is idempotent on the controller side. // A silent retry could leave us with duplicate side effects we can't see. PATCH failures bubble up through our own error counting instead, so the caller gets to // decide what to do about it. this.dispatcher = new Pool("https://" + this.nvrAddress, { allowH2: true, clientTtl: 60 * 1000, connect: { rejectUnauthorized: false }, connections: 5 }) .compose(ua, interceptors.retry({ maxRetries: 5, maxTimeout: 1500, methods: ["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"], minTimeout: 100, statusCodes: [400, 404, 429, 500, 502, 503, 504], timeoutFactor: 2 })); // Create a shared agent for WebSocket connections. We use a dedicated HTTP/1.1 agent because WebSocket upgrades cannot use HTTP/2. Keepalive is enabled to allow // TLS session reuse across WebSocket reconnections and to detect dead connections via TCP keepalive probes. Pipelining is disabled because WebSocket upgrades // cannot be pipelined. this.wsAgent = new Agent({ allowH2: false, connect: { keepAlive: true, rejectUnauthorized: false }, pipelining: 0 }); } } /** * Clear login credentials and terminate all API connections. * * @remarks * Performs a complete logout: * * - Clears authentication tokens