UNPKG

unifi-protect

Version:

A complete implementation of the UniFi Protect API.

978 lines 44.3 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, including access to the events, livestream data (not just RTSP), and websockets endpoints. * * The UniFi Protect API is largely undocumented and has been reverse engineered mostly through the Protect native web interface as well as trial and error. This * implementation provides a high-performance, event-driven interface into the Protect API, allowing you to access all of Protect's rich capabilities. * * @module ProtectApi */ import { AbortError, FetchError, Headers, context, reset, timeoutSignal } from "@adobe/fetch"; import { PROTECT_API_ERROR_LIMIT, PROTECT_API_RETRY_INTERVAL, PROTECT_API_TIMEOUT } from "./settings.js"; import { EventEmitter } from "node:events"; import { ProtectApiEvents } from "./protect-api-events.js"; import { ProtectLivestream } from "./protect-api-livestream.js"; import WebSocket from "ws"; import util from "node:util"; /** * This class provides an event-driven API to access the UniFi Protect API. Here's how to quickly get up and running with this library once you've instantiated the class: * * 1. {@link login | Login} to the UniFi Protect controller and acquire security credentials for further calls to the API. * * 2. Retrieve the current configuration and state of the Protect controller by calling the {@link bootstrap} endpoint. This contains everything you would want to know * about this particular UniFi Protect controller, including enumerating all the devices it knows about. * * 3. Listen for `message` events emitted by {@link ProtectApi} containing all Protect controller events, in realtime. They are delivered as * {@link ProtectApiEvents.ProtectEventPacket | ProtectEventPacket} packets, containing the event-specific details. * * Those are the basics that gets us up and running. */ export class ProtectApi extends EventEmitter { _bootstrap; _eventsWs; apiErrorCount; apiLastSuccess; fetch; headers; _isAdminUser; log; nvrAddress; password; username; /** * Create an instance of the UniFi Protect API. * * @param log - Logging functions to use. * * @defaultValue `none` - Logging will be done to stdout and stderr. * * @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.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.fetch = context({ alpnProtocols: ["h2" /* ALPNProtocol.ALPN_HTTP2 */], maxCacheSize: 0, rejectUnauthorized: false, userAgent: "unifi-protect" }).fetch; this.headers = new Headers(); this.nvrAddress = ""; this.username = ""; this.password = ""; } /** * Execute a login attempt to the UniFi Protect API. * * @param nvrAddress - Address of the UniFi Protect controller, expressed as an FQDN or IP address. * @param username - Username to use when logging into the controller. * @param password - Password to use when logging into the controller. * * @returns Returns a promise that will resolve to `true` if successful and `false` otherwise. * * @remarks A `login` event will be emitted each time this method is called, with the result of the attempt as an argument. If there are any existing logins from prior * calls to login, they will be terminated. * * @example * Login to the Protect controller. You can selectively choose to either `await` the promise that is returned by `login`, or subscribe to the `login` event. * * ```ts * import { ProtectApi } from "unifi-protect"; * * // Create a new Protect API instance. * const ufp = new ProtectApi(); * * // Set a listener to wait for the login event to occur. * ufp.once("login", (successfulLogin: boolean) => { * * // Indicate if we are successful. * if(successfulLogin) { * * console.log("Logged in successfully."); * process.exit(0); * } * }); * * // Login to the Protect controller. * if(!(await ufp.login("protect-controller.local", "username", "password"))) { * * console.log("Invalid login credentials."); * process.exit(0); * }; * ``` * * @category Authentication */ async login(nvrAddress, username, password) { this.logout(); this.nvrAddress = nvrAddress; this.username = username; this.password = password; // 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.has("Cookie") && this.headers.has("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.has("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" }, false); if (response?.ok) { const csrfToken = response.headers.get("X-CSRF-Token"); // Preserve the CSRF token, if found, for future API calls. if (csrfToken) { this.headers.set("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 (!response?.ok) { this.logout(); return false; } // We're logged in. Let's configure our headers. const csrfToken = response.headers.get("X-Updated-CSRF-Token") ?? response.headers.get("X-CSRF-Token"); const cookie = response.headers.get("Set-Cookie"); // 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.set("Cookie", cookie.split(";")[0]); // Save the CSRF token. this.headers.set("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) { // If we're retrying, let's first reset our connection context compleely. if (retry) { await reset(); } // 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 (!response?.ok) { 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. 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 { const ws = new WebSocket("wss://" + this.nvrAddress + "/proxy/protect/ws/updates?" + params.toString(), { headers: { Cookie: this.headers.get("Cookie") ?? "" }, rejectUnauthorized: false }); if (!ws) { this.log.error("Unable to connect to the realtime update events API. Will retry again later."); this._eventsWs = null; return false; } let messageHandler; // Cleanup after ourselves if our websocket closes for some resaon. ws.once("close", () => { this._eventsWs = null; if (messageHandler) { ws.removeListener("message", messageHandler); messageHandler = null; } }); // Handle any websocket errors. ws.once("error", (error) => { // If we're closing before fully established it's because we're shutting down the API - ignore it. if (error.message !== "WebSocket was closed before the connection was established") { this.log.error(error.toString()); } ws.terminate(); }); // Process messages as they come in. ws.on("message", messageHandler = (event) => { const packet = ProtectApiEvents.decodePacket(this.log, event); if (!packet) { this.log.error("Unable to process message from the realtime update events API."); ws.terminate(); return; } // Emit the decoded packet for users. this.emit("message", packet); }); // Make the websocket available, and then we're done. this._eventsWs = ws; } 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 Returns a promise that will resolve to `true` if successful and `false` otherwise. * * @remarks A `bootstrap` event will be emitted each time this method is successfully called, with the {@link ProtectNvrBootstrap} JSON as an argument. * * @example * Retrieve the bootstrap JSON. You can selectively choose to either `await` the promise that is returned by `getBootstrap`, or subscribe to the `bootstrap` event. * * ```ts * import { ProtectApi, ProtectNvrBootstrap } from "unifi-protect"; * import util from "node:util"; * * // Create a new Protect API instance. * const ufp = new ProtectApi(); * * // Set a listener to wait for the bootstrap event to occur. * ufp.once("bootstrap", (bootstrapJSON: ProtectNvrBootstrap) => { * * // Once we've bootstrapped the Protect controller, output the bootstrap JSON and we're done. * process.stdout.write(util.inspect(bootstrapJSON, { colors: true, depth: null, sorted: true }) + "\n", () => process.exit(0)); * }); * * // Login to the Protect controller. * if(!(await ufp.login("protect-controller.local", "username", "password"))) { * * console.log("Invalid login credentials."); * process.exit(0); * }; * * // Bootstrap the controller. It will emit a message once it's received the bootstrap JSON, or you can alternatively wait for the promise to resolve. * if(!(await ufp.getBootstrap())) { * * console.log("Unable to bootstrap the Protect controller."); * process.exit(0); * } * ``` * * Alternatively, you can access the bootstrap JSON directly through the {@link bootstrap} accessor: * * ```ts * import { ProtectApi } from "unifi-protect"; * import util from "node:util"; * * // Create a new Protect API instance. * const ufp = new ProtectApi(); * * // Login to the Protect controller. * if(!(await ufp.login("protect-controller.local", "username", "password"))) { * * console.log("Invalid login credentials."); * process.exit(0); * }; * * // Bootstrap the controller. * if(!(await ufp.getBootstrap())) { * * console.log("Unable to bootstrap the Protect controller."); * process.exit(0); * } * * // Once we've bootstrapped the Protect controller, access the bootstrap JSON through the bootstrap accessor and we're done. * process.stdout.write(util.inspect(ufp.bootstrap, { colors: true, depth: null, sorted: true }) + "\n", () => process.exit(0)); * ``` * * @category API Access */ // Get our UniFi Protect NVR configuration. 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 device. * @param options - Parameters to pass on for the snapshot request. * * @returns Returns a promise that will resolve to a Buffer containing the JPEG image snapshot if successful, and `null` otherwise. * * @remarks The `options` object for snapshot parameters accepts the following properties, all of which are optional: * * | Property | Description | * |-------------------|------------------------------------------------------------------------------------------------------------| * | height | The image height to request. Defaults selected by the Protect controller, based on the camera resolution. | * | width | The image width to request. Defaults selected by the Protect controller, based on the camera resolution. | * | usePackageCamera | Retriver a snapshot fron the package camera rather than the primary camera lens. Defaults to `false`. | * * @category API Access */ async getSnapshot(device, options = {}) { // No device object, or it's not a camera, or we're requesting a package camera snapshot on a camera without one - we're done. if (!device || (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?.ok) { 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. * @param payload - Device configuration payload to upload, usually a subset of the device-specific configuration JSON. * * @returns Returns a promise that will resolve to the updated device-specific configuration JSON if successful, and `null` otherwise. * * @remarks Use this method to change the configuration of a given Protect device or controller. It requires the credentials used to login to the Protect API * to have administrative privileges for most settings. * * @category API Access */ async updateDevice(device, payload) { // No device object, we're done. if (!device) { return null; } // 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 (!response.ok) { 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" }, true, false); // Since we took responsibility for interpreting the outcome of the fetch, we need to check for any errors. if (!response || !response?.ok) { 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 Returns a promise that will resolve to the updated {@link ProtectCameraConfig} if successful, and `null` otherwise. * * @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 - Optional name for the device. Defaults to the device type (e.g. `G4 Pro`). * @param deviceInfo - Optionally specify whether or not to include the IP address and MAC address in the returned string. Defaults to `false`. * * @returns Returns the Protect device name in the following format: <code>*Protect device name* [*Protect device type*] (address: *IP address* * mac: *MAC address*)</code>. * * @remarks The example above assumed the `deviceInfo` parameter is set to `true`. * * @category Utilities */ getDeviceName(device, name = device?.name, deviceInfo = false) { // Validate our inputs. if (!device) { return ""; } // 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, nicely formatted device and NVR string. * * @param device - Protect device. * * @returns Returns the Protect device name in the following format: * <code>*Protect controller name* [*Protect controller type*] *Protect device name* [*Protect device type*]</code>. * * @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. * * @category Utilities */ reset() { this._bootstrap = null; this._eventsWs?.terminate(); this._eventsWs = null; } /** * Clear the login credentials and terminate any open connection to the UniFi Protect API. * * @category Authentication */ // Utility to clear out old login credentials or attempts. logout() { // Close any connection to the Protect API. this.reset(); // Reset our parameters. this._isAdminUser = false; // Save our CSRF token, if we have one. const csrfToken = this.headers?.get("X-CSRF-Token"); // Initialize the headers we need. this.headers = new Headers(); this.headers.set("Content-Type", "application/json"); // Restore the CSRF token if we have one. if (csrfToken) { this.headers.set("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 - Requested endpoint type. Valid types are `livestream` and `talkback`. * @param params - Parameters to pass on for the endpoint request. * * @returns Returns a promise that will resolve to a URL to the requested endpoint if successful, and `null` otherwise. * * @remarks Valid API endpoints are `livestream` and `talkback`. * * - The `livestream` endpoint will return a URL to a websocket that provides an encoded livestream from a given camera. **Do not access this endpoint directly, use * {@link createLivestream} instead.** Accessing the livestream endpoint directly is not directly useful without additional manipulation, which, unless you have * a need for, you should avoid dealing with and use the {@link ProtectLivestream} API instead that provides you direct access to the livestream as an H.264 fMP4. * - The `talkback` endpoint creates a talkback connection to a Protect camera that contains a speaker (e.g. Protect doorbells). * The returned websocket accepts an AAC-encoded ADTS stream. The only valid parameter is `camera`, containing the ID of the Protect camera you want to connect to. * * @category API Access */ // Return a WebSocket URL endpoint from the Protect controller for Protect API services (e.g. livestream, talkback). 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, retry = true) { if (!endpoint) { return null; } // 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() : ""), undefined, !retry); // Something went wrong, we're done here. if (!response?.ok) { // Only inform users if we have a response if we have something to say. if (response) { this.logRetry("API endpoint access error: " + response.status.toString() + " - " + response.statusText + ".", retry); } // We failed, but controllers are sometimes rebooted, we can lose our access token or something else may have occurred. Retry one time, after resetting the // connection. if (retry) { this.reset(); return this._getWsEndpoint(endpoint, params, false); } return null; } try { const responseJson = await response.json(); // Adjust the URL for our address. const responseUrl = new URL(responseJson.url); responseUrl.hostname = this.nvrAddress; // Return the URL to the websocket. return responseUrl.toString(); } catch (error) { if (error instanceof FetchError) { switch (error.code) { default: this.log.error("Unknown error while communicating with the controller: %s", error.message); break; } } else { this.log.error("An error occurred while communicating with the controller: %s.", error); } return null; } } /** * Execute an HTTP fetch request to the Protect controller. * * @param url - Complete URL to execute **without** any additional parameters you want to pass (e.g. https://unvr.local/proxy/protect/cameras/someid/snapshot). * @param options - Parameters to pass on for the endpoint request. * @param logErrors - Log errors that aren't already accounted for and handled, rather than failing silently. Defaults to `true`. * * @returns Returns a promise that will resolve to a Response object successful, and `null` otherwise. * * @remarks This method should be used when direct access to the Protect controller is needed, or when this library doesn't have a needed method to access * controller capabilities. `options` must be a * [Fetch API compatible](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#options) request options object. * * @category API Access */ // Communicate HTTP requests with a Protect controller. async retrieve(url, options = { method: "GET" }, logErrors = true) { return this._retrieve(url, options, logErrors); } // Internal interface to communicating HTTP requests with a Protect controller, with error handling. async _retrieve(url, options = { method: "GET" }, logErrors = true, decodeResponse = true, isRetry = false) { // Log errors if that's what the caller requested. const logError = (message, ...parameters) => { if (!logErrors) { return; } this.log.error(message, ...parameters); }; // We'll delay our retry by 50 to 150ms, with a little randomness thrown in for good measure, to give the controller a chance to recover from it's quirkiness. const retry = async () => { // Reset our connection context compleely. await reset(); return new Promise(resolve => setTimeout(() => resolve(this._retrieve(url, options, logErrors, decodeResponse, true)), Math.floor(Math.random() * (150 - 50 + 1)) + 50)); }; // Catch Protect controller server-side issues: // // 400: Bad request. // 404: Not found. // 429: Too many requests. // 500: Internal server error. // 502: Bad gateway. // 503: Service temporarily unavailable. const isServerSideIssue = (code) => [400, 404, 429, 500, 502, 503].some(x => x === code); let response; // Create a signal handler to deliver the abort operation. const signal = timeoutSignal(PROTECT_API_TIMEOUT); options.headers = this.headers; options.signal = signal; try { const now = Date.now(); // Throttle this after PROTECT_API_ERROR_LIMIT attempts. if (this.apiErrorCount >= PROTECT_API_ERROR_LIMIT) { // Let the user know we've got an API problem. if (this.apiErrorCount === PROTECT_API_ERROR_LIMIT) { this.log.error("Throttling API calls due to errors with the %s previous attempts. Pausing communication with the Protect controller for %s minutes.", this.apiErrorCount, PROTECT_API_RETRY_INTERVAL / 60); this.apiErrorCount++; this.apiLastSuccess = now; this.reset(); return null; } // Check to see if we are still throttling our API calls. if ((this.apiLastSuccess + (PROTECT_API_RETRY_INTERVAL * 1000)) > now) { return null; } // Inform the user that we're out of the penalty box and try again. this.log.error("Resuming connectivity to the UniFi Protect API after pausing for %s minutes.", PROTECT_API_RETRY_INTERVAL / 60); this.apiErrorCount = 0; this.reset(); if (!(await this.loginController())) { return null; } } // Execute the API request. response = await this.fetch(url, options); // The caller will sort through responses instead of us. if (!decodeResponse) { return response; } // Preemptively increase the error count, but only if we're not retrying. if (!isRetry) { this.apiErrorCount++; } // Bad username and password. if (response.status === 401) { this.logout(); logError("Invalid login credentials given. Please check your login and password."); return null; } // Insufficient privileges. if (response.status === 403) { logError("Insufficient privileges for this user. Please check the roles assigned to this user and ensure it has sufficient privileges."); return null; } if (!response.ok && isServerSideIssue(response.status)) { // Retry on server side status issues, but no more than once. if (!isRetry) { return retry(); } logError("Unable to connect to the Protect controller. This is usually temporary and will occur during device reboots."); return null; } // Some other unknown error occurred. if (!response.ok) { logError("%s - %s", response.status, response.statusText); return null; } // We're all good - return the response and we're done. this.apiLastSuccess = Date.now(); this.apiErrorCount = 0; return response; } catch (error) { // Increment our API error count only if we're not in currently in a retry. if (!isRetry) { this.apiErrorCount++; } if (error instanceof AbortError) { // Retry on API timeouts, but no more than once. if (!isRetry) { return retry(); } logError("Protect controller is taking too long to respond to a request. This error can usually be safely ignored."); this.log.debug("Original request was: %s", url); return null; } if (error instanceof FetchError) { switch (error.code) { case "ECONNREFUSED": case "EHOSTDOWN": // Retry on connection refused, but no more than once. if (!isRetry) { return retry(); } logError("Connection refused."); break; case "ECONNRESET": case "ERR_HTTP2_STREAM_CANCEL": case "ERR_HTTP2_STREAM_ERROR": // Retry on connection reset, but no more than once. if (!isRetry) { return retry(); } logError("Network connection to Protect controller has been reset."); break; case "ENOTFOUND": logError("Hostname or IP address not found: %s. Please ensure the address you configured for this UniFi Protect controller is correct.", this.nvrAddress); break; default: // If we're logging when we have an error, do so. logError("Error: %s | %s.", error.code, error.message); break; } } return null; } finally { // Clear out our response timeout. signal.clear(); } } // Utility function for logging connection retries. logRetry(logMessage, isRetry) { // If we're over the API limit, no need to continue indicating errors since we already inform users we're throttling API calls. if (this.apiErrorCount >= PROTECT_API_ERROR_LIMIT) { return; } // If we're retrying, only log when debugging. if (isRetry) { this.log.debug("%s Retrying.", logMessage); } else { this.log.error(logMessage); } } /** * Return a new instance of the Protect livestream API. * * @returns Returns a new livestream API object. * * @remarks This method should be used to create a new livestream API object. It allows you to create access livestreams of individual cameras and interact * directly with the H.264 fMP4 streams for a given camera. * * @category API Access */ createLivestream() { return new ProtectLivestream(this, this.log); } /** * Return an API endpoint for the requested endpoint type. * * @param endpoint - Requested endpoint type. * * @returns Returns a URL to the requested endpoint if successful, and an empty string otherwise. * * @remarks Valid API endpoints are `bootstrap`, `camera`, `chime`, `light`, `login`, `nvr`, `self`, `sensor`, `websocket` and `viewer`. * * @category API Access */ getApiEndpoint(endpoint) { let endpointSuffix; let endpointPrefix = "/proxy/protect/api/"; switch (endpoint) { case "bootstrap": endpointSuffix = "bootstrap"; break; case "camera": endpointSuffix = "cameras"; break; case "chime": endpointSuffix = "chimes"; break; case "light": endpointSuffix = "lights"; break; case "login": endpointPrefix = "/api/"; endpointSuffix = "auth/login"; break; case "nvr": endpointSuffix = "nvr"; break; case "self": endpointPrefix = "/api/"; endpointSuffix = "users/self"; break; case "sensor": endpointSuffix = "sensors"; break; case "websocket": endpointSuffix = "ws"; break; case "viewer": endpointSuffix = "viewers"; break; default: break; } if (!endpointSuffix) { return ""; } return "https://" + this.nvrAddress + endpointPrefix + endpointSuffix; } /** * Access the Protect controller bootstrap JSON. * * @returns Returns the bootstrap JSON if the Protect controller has been bootstrapped, `null` otherwise. * * @remarks A call to {@link getBootstrap} is required before calling this getter. Otherwise, it will return `null`. Put another way, you need to execute a bootstrap * request to the Protect controller before accessing the bootstrap JSON. * * @category API Access */ get bootstrap() { return this._bootstrap; } /** * Utility method that returns whether the credentials that were used to login to the Protect controller have administrative privileges or not. * * @returns Returns `true` if the logged in user has administrative privileges, `false` otherwise. * * @category Utilities */ get isAdminUser() { return this._isAdminUser; } /** * Utility method that returns whether our connection to the Protect controller is currently throttled or not. * * @returns Returns `true` if the API has returned too many errors and is now throttled for a period of time, `false` otherwise. * * @category Utilities */ get isThrottled() { return this.apiErrorCount >= PROTECT_API_ERROR_LIMIT; } /** * Utility method that returns a nicely formatted version of the Protect controller name. * * @returns Returns the Protect controller name in the following format: * <code>*Protect controller name* [*Protect controller type*]</code>. * * @category Utilities */ get name() { // Our NVR string, if it exists, appears as `NVR [NVR Type]`. Otherwise, we appear as `NVRaddress`. if (this._bootstrap?.nvr) { return this._bootstrap.nvr.name + " [" + this._bootstrap.nvr.type + "]"; } else { return this.nvrAddress; } } } //# sourceMappingURL=protect-api.js.map