UNPKG

unifi-protect-native

Version:

A complete implementation of the UniFi Protect API.

1,279 lines 68.7 kB
/* Copyright(C) 2019-2025, 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} cameras`); * * // Listen for real-time events * protect.on("message", (packet) => { * console.log("Event received:", packet); * }); * ``` * * ## Architecture * * The API is built for modern Expo / React Native environments: * - **Fetch API**: Handles HTTPS requests with automatic cookie management * - **WebSockets**: Real-time bidirectional communication for events and livestreams * - **EventEmitter3**: Lightweight event-driven architecture that works across runtimes * * ## 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 EventEmitter from "eventemitter3"; import { ProtectApiEvents } from "./protect-api-events.js"; import { ProtectLivestream } from "./protect-api-livestream.js"; import { PROTECT_API_ERROR_LIMIT, PROTECT_API_RETRY_INTERVAL, PROTECT_API_TIMEOUT } from "./settings.js"; const formatError = (value) => { if (value instanceof Error) { return value.stack ?? value.message; } if (typeof value === "string") { return value; } try { return JSON.stringify(value, null, 2); } catch { return String(value); } }; const getNavigatorProduct = () => { const globalNavigator = globalThis.navigator; return globalNavigator?.product; }; const isReactNativeRuntime = () => getNavigatorProduct() === "ReactNative"; let nativeCookieManagerPromise = null; const getNativeCookieManager = async () => { if (!isReactNativeRuntime()) { return null; } nativeCookieManagerPromise ??= import("@react-native-cookies/cookies").then((module) => module?.default ?? null).catch(() => null); return nativeCookieManagerPromise; }; /** * 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 ProtectApiEvents.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 ProtectApiEvents.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; apiLastSuccess; headers; _isAdminUser; _isThrottled; hasAuthSession; log; nvrAddress; password; username; /** * 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.hasAuthSession = 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.apiLastSuccess = 0; this.headers = {}; this.nvrAddress = ""; this.username = ""; this.password = ""; } /** * 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} 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; await this.clearCookieJar(); 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; } async clearCookieJar() { try { const cookieManager = await getNativeCookieManager(); if (!cookieManager?.clearAll) { return; } await cookieManager.clearAll(true); } catch (error) { this.log.debug("Unable to clear UniFi Protect cookie jar: %s", formatError(error)); } } // Login to the UniFi Protect API. async loginController() { // If we're already logged in, we're done. if (this.hasAuthSession && this.headers["x-csrf-token"]) { return true; } // Acquire a CSRF token, if needed. We only need to do this if we aren't already logged in, or we don't already have a token. if (!this.headers["x-csrf-token"]) { // UniFi OS has cross-site request forgery protection built into it's web management UI. We retrieve the CSRF token, if available, by connecting to the Protect // controller and checking the headers for it. const response = await this.retrieve("https://" + this.nvrAddress, { method: "GET" }, { logErrors: false }); if (this.responseOk(response?.status)) { const csrfToken = this.getHeaderValue(response?.headers ?? null, "X-CSRF-Token"); // Preserve the CSRF token, if found, for future API calls. if (csrfToken) { this.headers["x-csrf-token"] = csrfToken; } } } // Log us in. const response = await this.retrieve(this.getApiEndpoint("login"), { body: JSON.stringify({ password: this.password, rememberMe: true, token: "", username: this.username }), method: "POST" }); // Something went wrong with the login call, possibly a controller reboot or failure. if (!this.responseOk(response?.status)) { this.logout(); return false; } // We're logged in. Let's configure our headers. const csrfToken = this.getHeaderValue(response?.headers, "X-Updated-CSRF-Token") ?? this.getHeaderValue(response?.headers, "X-CSRF-Token"); const cookie = this.getHeaderValue(response?.headers, "Set-Cookie"); // Save the refreshed cookie and CSRF token for future API calls and we're done. if (csrfToken) { // Save the CSRF token. this.headers["x-csrf-token"] = csrfToken; if (cookie) { this.headers.cookie = cookie.split(";")[0]; } this.hasAuthSession = true; 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")); if (!response) { this.logRetry("Unable to retrieve the UniFi Protect controller configuration.", retry); return retry ? this.bootstrapController(false) : false; } // Something went wrong. Retry the bootstrap attempt once, and then we're done. if (!this.responseOk(response.status)) { 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.json(); } catch (error) { data = null; this.log.error("Unable to parse response from UniFi Protect. Will retry again later."); } // Is this the first time we're bootstrapping? const isFirstRun = this._bootstrap ? false : true; // 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 ProtectApiEvents.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. const ws = new WebSocket("wss://" + this.nvrAddress + "/proxy/protect/ws/updates?" + params.toString()); let messageHandler; // Fired when the handshake completes ws.addEventListener("open", () => { // Make the WebSocket available. this._eventsWs = ws; }, { once: true }); // Handle any WebSocket errors. ws.addEventListener("error", (event) => { const details = event.error ?? event.message ?? "Unknown error"; this.log.error("Events API error: %s", formatError(details)); ws.close(); }, { once: true }); // Cleanup after ourselves if our WebSocket closes for some resaon. ws.addEventListener("close", () => { this._eventsWs = null; if (messageHandler) { ws.removeEventListener("message", messageHandler); messageHandler = null; } }, { once: true }); // Process messages as they come in. ws.addEventListener("message", messageHandler = async (event) => { try { // We need to normalize event.data into an ArrayBuffer so we can process it. let ab; if (event.data instanceof Blob) { ab = await event.data.arrayBuffer(); } else if (event.data instanceof ArrayBuffer) { ab = event.data; } else if (typeof event.data === "string") { ab = new TextEncoder().encode(event.data).buffer; } else { this.log.error("Unsupported WebSocket message type: %s", typeof event.data); ws.close(); return; } // Now we decode our packet. const packet = ProtectApiEvents.decodePacket(this.log, Buffer.from(ab)); if (!packet) { this.log.error("Unable to process message from the realtime update events API."); ws.close(); return; } // Emit the decoded packet for users. this.emit("message", packet); } catch (error) { this.log.error("Error processing events WebSocket message: ", error); ws.close(); } }); } catch (error) { this.log.error("Error connecting to the realtime update events API: %s", error); } 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(`Uptime: ${bootstrap.nvr.uptime} seconds`); * * // Device inventory * console.log(`Cameras: ${bootstrap.cameras.length}`); * console.log(`Lights: ${bootstrap.lights.length}`); * console.log(`Sensors: ${bootstrap.sensors.length}`); * * // 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} 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 * as FileSystem from "expo-file-system"; * * 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) { * const snapshots = [ * { label: "full", data: await protect.getSnapshot(camera) }, * { label: "thumb", data: await protect.getSnapshot(camera, { width: 640, height: 360 }) } * ]; * * if (camera.featureFlags.hasPackageCamera) { * snapshots.push({ * label: "package", * data: await protect.getSnapshot(camera, { usePackageCamera: true }) * }); * } * * for (const snapshot of snapshots) { * if (!snapshot.data) { * continue; * } * * const fileUri = `${FileSystem.cacheDirectory}${camera.id}-${snapshot.label}.jpg`; * await FileSystem.writeAsStringAsync( * fileUri, * snapshot.data.toString("base64"), * { encoding: FileSystem.EncodingType.Base64 } * ); * console.log(`Saved ${snapshot.label} snapshot to ${fileUri}`); * } * } * } * ``` * * @category API Access */ async getSnapshot(device, options = {}) { // It's not a camera, or we're requesting a package camera snapshot on a camera without one - we're done. if ((device.modelKey !== "camera") || (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.status)) { this.log.error("%s: Unable to retrieve the snapshot.", this.getFullName(device)); return null; } let snapshot; try { snapshot = Buffer.from(await response.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", * prePaddingSecs: 3, * postPaddingSecs: 3, * retentionDurationMs: 7 * 24 * 60 * 60 * 1000 // 7 days * } * }); * * // Configure motion detection * await protect.updateDevice(camera, { * motionSettings: { * mode: "always", * sensitivity: 80 * }, * smartDetectSettings: { * objectTypes: ["person", "vehicle"], * autoTrackingEnabled: true * } * }); * * // Update light device * const light = protect.bootstrap?.lights[0]; * if (light) { * await protect.updateDevice(light, { * lightSettings: { * mode: "motion", * brightness: 100, * durationMs: 15000 // 15 seconds * } * }); * } * * // Configure doorbell chime * const chime = protect.bootstrap?.chimes[0]; * if (chime) { * await protect.updateDevice(chime, { * volume: 75, * ringtones: ["traditional"] * }); * } * } * ``` * * @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), formatError(payload)); // 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.status)) { this.log.error("%s: Unable to configure the %s: %s.", this.getFullName(device), device.modelKey, response.status); return null; } // We successfully set the message, return the updated device object. return await response.json(); } // Update camera channels on a supported Protect device. async updateCameraChannels(device) { // Make sure we have the permissions to modify the camera JSON. if (!(await this.canModifyCamera(device))) { return null; } // Update Protect with the new configuration. const response = await this._retrieve(this.getApiEndpoint(device.modelKey) + "/" + device.id, { body: JSON.stringify({ channels: device.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.status)) { this.apiErrorCount++; if (response?.status === 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?.status); } // 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; this.apiLastSuccess = Date.now(); // Everything worked, save the new channel array. return await response.json(); } /** * 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 * updated.channels.forEach((channel, index) => { * if (channel.isRtspEnabled) { * const rtspUrl = `rtsp://192.168.1.1:7447/${channel.rtspAlias}`; * console.log(` Channel ${index}: ${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(device))) { return null; } // Do we have any non-RTSP enabled channels? If not, we're done. if (!device.channels.some(channel => !channel.isRtspEnabled)) { return device; } // Enable RTSP on all available channels. device.channels = device.channels.map((channel) => { channel.isRtspEnabled = true; return channel; }); // Update the camera channel JSON with our edits. return this.updateCameraChannels(device); } /** * 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 ?? []) * ]; * * allDevices.forEach(device => { * // 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"; * import { useEffect } from "react"; * * const protect = new ProtectApi(); * * export function useProtectConnection() { * useEffect(() => { * let mounted = true; * * (async () => { * if(!mounted) { * return; * } * * await protect.login("192.168.1.1", "admin", "password"); * await protect.getBootstrap(); * })(); * * return () => { * mounted = false; * protect.reset(); * }; * }, []); * } * ``` * * @category Utilities */ reset() { this._bootstrap = null; this._eventsWs?.close(); this._eventsWs = null; } /** * Clear login credentials and terminate all API connections. * * @remarks * Performs a complete logout: * * - Clears authentication tokens and cookies * - Terminates all active connections * - Resets user privilege status * - Preserves CSRF token for future logins * * After logout, a new {@link login} call is required to use the API again. * * @example * Switching between controllers: * * ```typescript * import { ProtectApi } from "unifi-protect"; * * const protect = new ProtectApi(); * * async function switchControllers() { * // Connect to first controller * await protect.login("192.168.1.1", "admin", "password1"); * await protect.getBootstrap(); * console.log(`Connected to ${protect.name}`); * * // Switch to second controller * protect.logout(); * * await protect.login("192.168.2.1", "admin", "password2"); * await protect.getBootstrap(); * console.log(`Connected to ${protect.name}`); * } * ``` * * @category Authentication */ logout() { // Close any connection to the Protect API. this.reset(); // Reset our parameters. this._isAdminUser = false; this.hasAuthSession = false; // Save our CSRF token, if we have one. const csrfToken = this.headers["x-csrf-token"]; // Initialize the headers we need. this.headers = {}; this.headers["content-type"] = "application/json"; // Restore the CSRF token if we have one. if (csrfToken) { this.headers["x-csrf-token"] = csrfToken; } } // Utility to validate that we have the privileges we need to modify the camera JSON. async canModifyCamera(device) { // Log us in if needed. if (!(await this.loginController())) { return false; } // Only admin users can activate RTSP streams. if (!this.isAdminUser) { return false; } // At the moment, we only know about camera devices. if (device.modelKey !== "camera") { return false; } return true; } /** * Return a websocket API endpoint for the requested endpoint type. * * @param endpoint - Endpoint type (`livestream` or `talkback`) * @param params - URL parameters for the endpoint * * @returns Promise resolving to the WebSocket URL, or `null` on failure. * * @remarks * This method provides access to real-time WebSocket endpoints: * * ### Livestream Endpoint * Returns a WebSocket URL for H.264 fMP4 video streams. **Do not use directly** - use {@link createLivestream} instead for proper stream handling. * * ### Talkback Endpoint * Creates a two-way audio connection to cameras with speakers (doorbells, two-way audio cameras). The WebSocket accepts AAC-encoded ADTS audio streams. * * Required parameter: * * - `camera`: The camera ID to connect to * * @example * Setting up two-way audio: * * ```typescript * import { ProtectApi } from "unifi-protect"; * import { WebSocket } from "ws"; * * const protect = new ProtectApi(); * * async function setupTalkback(cameraId: string) { * await protect.login("192.168.1.1", "admin", "password"); * * // Get the talkback endpoint * const params = new URLSearchParams({ camera: cameraId }); * const wsUrl = await protect.getWsEndpoint("talkback", params); * * if (!wsUrl) { * console.error("Failed to get talkback endpoint"); * return; * } * * // Connect to the WebSocket * const ws = new WebSocket(wsUrl); * * ws.on("open", () => { * console.log("Talkback connection established"); * // Send AAC-encoded audio data * // ws.send(aacAudioBuffer); * }); * * ws.on("error", (error) => { * console.error("Talkback error:", error); * }); * } * ``` * * @category API Access */ async getWsEndpoint(endpoint, params) { return this._getWsEndpoint(endpoint, params); } // Internal interface to returning a WebSocket URL endpoint from the Protect controller for Protect API services (e.g. livestream, talkback). async _getWsEndpoint(endpoint, params) { // Log us in if needed. if (!(await this.loginController())) { return null; } // Ask Protect to give us a URL for this websocket. const response = await this.retrieve(this.getApiEndpoint("websocket") + "/" + endpoint + ((params && params.toString().length) ? "?" + params.toString() : "")); // Something went wrong, we're done here. if (!response || !this.responseOk(response.status)) { // Only inform users if we have a response if we have something to say. if (response) { this.log.error("API endpoint access error: %s - %s.", response.status, response.statusText || "Unknown status"); } return null; } try { const responseJson = await response.json(); const endpointUrl = responseJson.directUrl || responseJson.url || false; if (!endpointUrl) { this.log.error("Protect controller did not return a websocket URL."); return null; } // Adjust the URL for our address. Prefer directUrl to avoid the proxy when available (livestream needs init segments from :7446). const responseUrl = new URL(endpointUrl); responseUrl.hostname = this.nvrAddress; // Return the URL to the websocket. return responseUrl.toString(); } catch (error) { if (error instanceof SyntaxError) { this.log.error("Received syntax error while communicating with the controller. This is typically due to a controller reboot."); } else { this.log.error("An error occurred while communicating with the controller: %s.", formatError(error)); } return null; } } /** * Execute an HTTP request to the Protect controller. * * @param url - Full URL to request (e.g., `https://192.168.1.1/proxy/protect/api/cameras`) * @param options - Standard Fetch API request options * @param retrieveOptions - Additional options for error handling and timeout