unifi-protect
Version:
A complete implementation of the UniFi Protect API.
1,157 lines • 75.9 kB
JavaScript
/* 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