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