unifi-access
Version:
A soon-to-be-almost-complete implementation of the UniFi Access API.
944 lines • 43.4 kB
JavaScript
/* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* access-api.ts: Our UniFi Access API implementation.
*/
import { ACCESS_API_ERROR_LIMIT, ACCESS_API_RETRY_INTERVAL, ACCESS_API_TIMEOUT } from "./settings.js";
import { Agent, Pool, WebSocket, errors, interceptors, request } from "undici";
import { EventEmitter } from "node:events";
import { STATUS_CODES } from "node:http";
import util from "node:util";
/**
* The direct UniFi Access API is partially documented through an officially supported public API that Ubiquiti has released. However, this API also has certain
* constraints and limitations such as lacking the ability to change the settings on an Access device. The full native API has been reverse engineered mostly through
* trail and error with the Access web interface as well as insight from the public API.
*
* Here's how the UniFi Access API works:
*
* 1. {@link login | Login} to the UniFi Access controller and acquire security credentials for further calls to the API.
*
* 2. Enumerate the list of UniFi Access devices by calling the {@link bootstrap} property. This contains everything you would want to know about the devices attached to
* this particular UniFi Access controller. Information about the Access controller can be accessed through the {@link controller} property.
*
* 3. Listen for `message` events emitted by {@link AccessApi} containing all Access controller events, in realtime. They are delivered as
* {@link access-types.AccessEventPacket} packets, containing the event-specific details.
*
* Those are the basics that gets us up and running.
*/
export class AccessApi extends EventEmitter {
_bootstrap;
_controller;
_devices;
_doors;
_floors;
_isAdminUser;
_isThrottled;
address;
apiErrorCount;
apiLastSuccess;
dispatcher;
events;
eventsTimer;
headers;
log;
password;
username;
/**
* Create an instance of the UniFi Access API.
*
* @param log - Logging functions to use.
*
* @defaultValue `none` - Logging will be done to stdout and stderr.
*/
// Initialize this instance with our login information.
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 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
debug: (_message, ..._parameters) => { },
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._controller = null;
this._devices = null;
this._doors = null;
this._floors = null;
this._isAdminUser = false;
this._isThrottled = false;
this.events = null;
this.eventsTimer = null;
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.address = "";
this.username = "";
this.password = "";
}
/**
* Execute a login attempt to the UniFi Access API.
*
* @param address - Address of the UniFi Access 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.
*
* @example
* Login to the Access controller. You can selectively choose to either `await` the promise that is returned by `login`, or subscribe to the `login` event.
*
* ```ts
* import { AccessApi } from "unifi-access";
*
* // Create a new Access API instance.
* const ufp = new AccessApi();
*
* // 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 Access controller.
* if(!(await ufa.login("access-controller.local", "username", "password"))) {
*
* console.log("Invalid login credentials.");
* process.exit(0);
* };
* ```
*/
// Login to the Access controller and terminate any existing login we might have.
async login(address, username, password) {
this.address = address;
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 Access 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;
};
// 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 Access
// controller and checking the headers for it.
const response = await this.retrieve("https://" + this.address, { method: "GET" }, { logErrors: false });
if (this.responseOk(response?.statusCode)) {
const csrfToken = getHeader("X-CSRF-Token", response?.headers);
// 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?.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 Access controller.
async bootstrapController(retry) {
// Log us in if needed.
if (!(await this.loginController())) {
return retry ? this.bootstrapController(false) : false;
}
// Utility to retrieve and parise API responses, with error handling.
const retrieveEndpoint = async (endpoint) => {
// Retrieve the endpoint from the controller.
const response = await this.retrieve(this.getApiEndpoint(endpoint));
// 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 Access " + endpoint + " configuration.", retry);
return null;
}
let data = null;
try {
data = await response?.body.json();
// @eslint-disable-next-line @typescript-eslint/no-unused-vars
}
catch (error) {
data = null;
this.log.error("Unable to parse response from UniFi Access. Will retry again later.");
}
return data;
};
// Retrieve the controller configuration.
this._controller = (await retrieveEndpoint("controller"))?.data ?? null;
if (!this._controller && retry) {
return this.bootstrapController(false);
}
// Next, retrieve the bootstrap configuration.
const data = (await retrieveEndpoint("bootstrap"))?.data;
this._bootstrap = data ? data[0] : null;
if (!this._bootstrap && retry) {
return this.bootstrapController(false);
}
// Retrieve the list of doors from all the floors the user has configured.
this._doors = this._bootstrap?.floors?.flatMap(floor => floor.doors).filter(Boolean) ?? null;
// In case we end up with an empty floors array due to changes in the Access API, we can conceivably end up with an empty array here.
this._doors = this._doors?.length ? this._doors : null;
// Retrieve the list of devices from all the doors the user has configured.
this._devices = this._doors?.map(x => x.device_groups ?? []).flat(2).filter(Boolean) ?? null;
// Some controllers expose device_groups at the top level in addition to (or instead of) within the floors/doors hierarchy. We merge them here.
if (Array.isArray(this._bootstrap?.device_groups)) {
const topLevelDeviceGroups = this._bootstrap.device_groups.flat().filter(Boolean);
this._devices = (this._devices ?? []).concat(topLevelDeviceGroups);
}
// In case we end up with an empty devices array due to changes in the Access API, we can conceivably end up with an empty array here.
this._devices = this._devices?.length ? this._devices : null;
// Account for Enterprise Access Hubs. What we do here is append to the devices array a transformed version of each extension (which in the case of an EAH amounts to
// the equivalent of a hub / lock) attached to it. We transform the configuration to make it appear like it's a typical UAH for our purposes, and we map the name and
// unlock location accordingly.
/* eslint-disable camelcase */
const eahDevices = this._bootstrap?.device_groups?.flat().filter(device => device.device_type === "UAH-Ent").
flatMap(({ extensions = [], alias, display_model, location_id, ...device }) => extensions.
map(({ target_name, target_value, ...extension }) => ({ ...device, ...extension, alias: target_name ?? "Unknown",
display_model: display_model + ((extension.source_id !== undefined) ? " " + extension.source_id.replace(/(^\w|\s+\w)/g, match => match.toUpperCase()) : ""),
location_id: target_value ?? location_id, name: alias })));
/* eslint-enable camelcase */
// Add EAH devices, if we have them.
if (eahDevices?.length) {
this._devices ??= [];
this._devices.push(...eahDevices);
}
// Set the list of floors as a convenience.
this._floors = this._bootstrap?.floors ?? null;
// If we're bootstrapped, connect to the event listener API. Otherwise, we're done.
if (!this._bootstrap || !(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 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.events) {
return true;
}
try {
const ws = new WebSocket("wss://" + this.address + "/proxy/access/api/v2/ws/notification", { dispatcher: new Agent({ connect: { rejectUnauthorized: false } }), headers: { Cookie: this.headers.cookie ?? "" } });
let messageHandler;
// Cleanup after ourselves if our websocket closes for some resaon.
ws.addEventListener("close", () => {
if (this.eventsTimer) {
clearTimeout(this.eventsTimer);
this.eventsTimer = null;
}
this.events = null;
if (messageHandler) {
ws.removeEventListener("message", messageHandler);
messageHandler = null;
}
}, { once: true });
// Handle any websocket errors.
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 });
// Process messages as they come in.
ws.addEventListener("message", messageHandler = (event) => {
if (!event) {
this.log.error("Unable to process message from the realtime events API.");
ws.close();
return;
}
// No event data - we're done.
if (!event.data) {
return;
}
// The Access events API seems to send a heartbeat every five seconds.
if (event.data === "\"Hello\"\n") {
// Heartbeat.
if (this.eventsTimer) {
clearTimeout(this.eventsTimer);
}
this.eventsTimer = setTimeout(() => {
this.log.error("Failed to detect heartbeat from the events API. Resetting the connection.");
this.reset();
}, 1000 * 10);
return;
}
// Access events are published as JSON objects.
try {
// Emit the decoded packet for users.
this.emit("message", JSON.parse(event.data));
}
catch (error) {
this.log.error("Error processing message from the events API: %s", error);
return;
}
});
// Make the websocket available, and then we're done.
this.events = ws;
// Establish our heartbeat.
this.eventsTimer = setTimeout(() => {
this.log.error("Failed to detect heartbeat from the events API. Resetting the connection.");
this.reset();
}, 1000 * 10);
}
catch (error) {
this.log.error("Error connecting to the realtime update events API: %s", error);
}
return true;
}
/**
* Retrieve the bootstrap JSON from a UniFi Access 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 AccessToplogyConfig JSON as an argument. As a
* convenience, the {@link devices}, {@link doors}, and {@link floors} properties will be populated as well.
*
* @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 { AccessApi, AccessDeviceConfig } from "unifi-access";
* import util from "node:util";
*
* // Create a new Access API instance.
* const ufa = new AccessApi();
*
* // Set a listener to wait for the bootstrap event to occur.
* ufa.once("bootstrap", (bootstrapJSON: AccessDeviceConfig) => {
*
* // Once we've bootstrapped the Access 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 Access controller.
* if(!(await ufa.login("access-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 ufa.getBootstrap())) {
*
* console.log("Unable to bootstrap the Access controller.");
* process.exit(0);
* }
* ```
*
* Alternatively, you can access the bootstrap JSON directly through the {@link bootstrap} accessor:
*
* ```ts
* import { AccessApi } from "unifi-access";
* import util from "node:util";
*
* // Create a new Access API instance.
* const ufa = new AccessApi();
*
* // Login to the Access controller.
* if(!(await ufa.login("access-controller.local", "username", "password"))) {
*
* console.log("Invalid login credentials.");
* process.exit(0);
* };
*
* // Bootstrap the controller.
* if(!(await ufa.getBootstrap())) {
*
* console.log("Unable to bootstrap the Access controller.");
* process.exit(0);
* }
*
* // Once we've bootstrapped the Access controller, access the bootstrap JSON through the bootstrap accessor and we're done.
* process.stdout.write(util.inspect(ufa.bootstrap, { colors: true, depth: null, sorted: true }) + "\n", () => process.exit(0));
* ```
*
*/
// Get our UniFi Access configuration.
async getBootstrap() {
// Bootstrap the controller, and attempt to retry the bootstrap if it fails.
return this.bootstrapController(true);
}
/**
* Send an unlock command to the Access controller.
*
* @param device - Access device.
* @param duration - Unlock interval in minutes.
*
* @returns Returns `true` if successful, `false` otherwise.
*
* @remarks If `duration` is not specified, a standard unlock request will be sent to the Access controller which will unlock for 2 seconds. Valid values for duration
* are `Infinity` - remain unlocked until reset, `0` - reset lock to secure state, `duration` - number of minutes.
*/
// Send an unlock command to a hub.
async unlock(device, duration) {
// No device object, we're done.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!device) {
return false;
}
// Unlocking only works on hubs.
if (!device.capabilities.includes("is_hub")) {
return false;
}
// Default to the standard unlock endpoint.
let action = "unlock";
let endpoint = this.getApiEndpoint("location") + "/" + device.location_id + "/unlock";
let payload = {};
// If we've specified a duration, let's specify that.
if (duration !== undefined) {
// eslint-disable-next-line camelcase
const params = new URLSearchParams({ get_result: "true" });
endpoint = this.getApiEndpoint("device") + "/" + device.unique_id + "/lock_rule?" + params.toString();
// Safety check for out of bounds values.
if (duration < 0) {
duration = 0;
}
switch (duration) {
case 0:
action = "lock";
payload = { type: "reset" };
break;
case Infinity:
payload = { type: "keep_unlock" };
break;
default:
payload = { interval: Math.trunc(duration), type: "custom" };
}
}
else {
// For undefined duration, use the generic location unlock endpoint (for gates). We prefer the door/gate location from extensions over the device's building
// location.
const locationId = device.extensions?.find(ext => ext.extension_name === "port_setting")?.target_value ?? device.location_id;
endpoint = this.getApiEndpoint("location") + "/" + locationId + "/unlock";
}
// Request the unlock from Access.
const response = await this.retrieve(endpoint, {
body: JSON.stringify(payload),
method: "PUT"
});
if (!this.responseOk(response?.statusCode)) {
this.log.error("%s: Unable to %s the %s: %s.", this.getFullName(device), action, device.display_model, response?.statusCode);
return false;
}
// Get our status.
const status = await response?.body.json();
if (status.codeS === "SUCCESS") {
return true;
}
// We failed - let's log what we know.
this.log.error("%s: Error %sing the %s: \n%s", this.getFullName(device), action, device.display_model, util.inspect(status, { colors: false, depth: null, sorted: true }));
return false;
}
/**
* Update an Access device's configuration on the UniFi Access controller.
*
* @typeParam DeviceType - Generic for any known Access device type.
*
* @param device - Access 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 Access device or controller. It requires the credentials used to login to the Access API
* to have administrative privileges for most settings.
*/
// Update an Access device object.
async updateDevice(device, payload) {
// No device object, we're done.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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 Access with the new configuration.
const response = await this.retrieve(this.getApiEndpoint("device") + "/" + device.unique_id + (device.capabilities.includes("is_hub") ? "configs" : "settings"), {
body: JSON.stringify(payload),
method: "PUT"
});
// 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.device_type, response.statusCode);
return null;
}
// We successfully set the message, return the updated device object.
return await response.body.json();
}
/**
* Utility method that generates a nicely formatted device information string.
*
* @param device - Access device.
* @param name - Optional name for the device. Defaults to the device type (e.g. `UA G2 Pro Black`).
* @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 Access device name in the following format: <code>*Access device name* [*Access device type*] (address: *IP address* mac: *MAC address*)</code>.
*
* @remarks The example above assumed the `deviceInfo` parameter is set to `true`.
*/
// Utility to generate a nicely formatted device string.
getDeviceName(device, name = device.alias?.length ? device.alias : device.name, deviceInfo = false) {
// Include the host address information, if we have it.
const host = (("ip" in device) && device.ip) ? "address: " + device.ip + " " : "";
const type = (("display_model" in device) && device.display_model) ? device.display_model : device.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.replace(/:/g, "").toUpperCase() + ")" : "");
}
/**
* Utility method that generates a combined, nicely formatted device and controller string.
*
* @param device - Access device.
*
* @returns Returns the Access device name in the following format:
* <code>*Access controller name* [*Access controller type*] *Access device name* [*Access device type*]</code>.
*/
// Utility to generate a nicely formatted controller and device string.
getFullName(device) {
const deviceName = this.getDeviceName(device);
// Returns: Controller [Controller Type] Device Name [Device Type]
return this.name + (deviceName.length ? " " + deviceName : "");
}
/**
* Terminate any open connection to the UniFi Access API.
*/
// Utility to disconnect from the Access controller and reset the connection.
reset() {
this._bootstrap = null;
this._floors = null;
this._doors = null;
this._devices = null;
if (this.eventsTimer) {
clearTimeout(this.eventsTimer);
}
this.eventsTimer = null;
this.events?.close();
this.events = null;
if (this.address) {
// 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-access";
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 retry each request up to three times, with backoff.
this.dispatcher = new Pool("https://" + this.address, { 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 }));
}
}
/**
* Clear the login credentials and terminate any open connection to the UniFi Access API.
*/
// Utility to clear out old login credentials or attempts.
logout() {
// Close any connection to the Access API.
this.reset();
// Reset our parameters.
this._isAdminUser = 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;
}
}
/**
* Execute an HTTP fetch request to the Access controller.
*
* @param url - Full URL to request (e.g., `https://192.168.1.1/proxy/access/api/v2/devices/topologyv4`)
* @param options - Parameters to pass on for the endpoint request.
* @param retrieveOptions - Additional options for error handling and timeouts
*
* @returns Promise resolving to the Response object, or `null` on failure.
*
* @remarks
* This method provides direct access to the Protect controller API for advanced use cases not covered by the built-in methods. It handles:
*
* - Authentication and session management
* - Automatic retry with exponential backoff
* - Error logging and throttling
* - CSRF token management
*
* The `options` parameter extends [Undici's RequestOptions](https://undici.nodejs.org/#/docs/api/Dispatcher.md?id=parameter-requestoptions), providing full control
* over the HTTP request.
*/
// Communicate HTTP requests with a Access controller.
async retrieve(url, options = { method: "GET" }, retrieveOptions = {}) {
return this._retrieve(url, options, retrieveOptions);
}
// Internal interface to communicating HTTP requests with an Access controller, with error handling.
async _retrieve(url, options = { method: "GET" }, retrieveOptions = {}) {
// Set our defaults unless the user has overriden them.
retrieveOptions.decodeResponse ??= true;
retrieveOptions.logErrors ??= true;
retrieveOptions.timeout ??= ACCESS_API_TIMEOUT;
// Log errors if that's what the caller requested.
const logError = (message, ...parameters) => {
if (!retrieveOptions.logErrors) {
return;
}
this.log.error(message, ...parameters);
};
// Catch Access 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 serverErrors = new Set([400, 404, 429, 500, 502, 503]);
let response;
// Create a signal handler to deliver the abort operation.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), retrieveOptions.timeout);
options.dispatcher = this.dispatcher;
options.headers = this.headers;
options.signal = controller.signal;
try {
const now = Date.now();
// Throttle this after ACCESS_API_ERROR_LIMIT attempts.
if (this.apiErrorCount >= ACCESS_API_ERROR_LIMIT) {
// Let the user know we've got an API problem.
if (!this._isThrottled) {
this.apiLastSuccess = now;
this._isThrottled = true;
this.log.error("Throttling API calls due to errors with the %s previous attempts. Pausing communication with the Access controller for %s minutes.", this.apiErrorCount++, ACCESS_API_RETRY_INTERVAL / 60);
this.reset();
return null;
}
// Check to see if we are still throttling our API calls.
if ((this.apiLastSuccess + (ACCESS_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 Access API after pausing for %s minutes.", ACCESS_API_RETRY_INTERVAL / 60);
this.apiErrorCount = 0;
this._isThrottled = false;
if (!(await this.loginController())) {
return null;
}
}
response = await request(url, options);
// The caller will sort through responses instead of us.
if (!retrieveOptions.decodeResponse) {
return response;
}
// Preemptively increase the error count.
this.apiErrorCount++;
// Bad username and password.
if (response.statusCode === 401) {
this.logout();
logError("Invalid login credentials given. Please check your login and password.");
return null;
}
// Insufficient privileges.
if (response.statusCode === 403) {
logError("Insufficient privileges for this user. Please check the roles assigned to this user and ensure it has sufficient privileges.");
return null;
}
if (!this.responseOk(response.statusCode)) {
if (serverErrors.has(response.statusCode)) {
logError("Unable to connect to the Access controller. This is usually temporary and will occur during device reboots.");
return null;
}
// Some other unknown error occurred.
logError("%s - %s", response.statusCode, STATUS_CODES[response.statusCode]);
return null;
}
this.apiLastSuccess = Date.now();
this.apiErrorCount = 0;
this._isThrottled = false;
return response;
}
catch (error) {
// Increment our API error count.
this.apiErrorCount++;
// We aborted the connection.
if ((error instanceof DOMException) && (error.name === "AbortError")) {
logError("Access 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;
}
// We destroyed the pool due to a reset event and our inflight connections are failing.
if (error instanceof errors.ClientDestroyedError) {
return null;
}
// We destroyed the pool due to a reset event and our inflight connections are failing.
if (error instanceof errors.RequestRetryError) {
logError("Unable to connect to the Access controller. This is usually temporary and will occur during device reboots.");
return null;
}
// Connection timed out.
if (error instanceof errors.ConnectTimeoutError) {
logError("Connection timed out.");
return null;
}
let cause;
if (error instanceof TypeError) {
cause = error.cause;
}
if ((error instanceof Error) && ("code" in error) && (typeof error.code === "string")) {
cause = error;
}
if (cause) {
switch (cause.code) {
case "ECONNREFUSED":
case "EHOSTDOWN":
logError("Connection refused.");
break;
case "ECONNRESET":
logError("Network connection to Access controller has been reset.");
break;
case "ENOTFOUND":
if (this.address) {
logError("Hostname or IP address not found: %s. Please ensure the address you configured for this UniFi Access controller is correct.", this.address);
}
else {
logError("No hostname or IP address provided.");
}
break;
default:
// If we're logging when we have an error, do so.
logError("Error: %s | %s.", cause.code, cause.message);
break;
}
return null;
}
logError("Unknown error: %s", util.inspect(error, { colors: true, depth: null, sorted: true }));
return null;
}
finally {
// Clear out our response timeout.
clearTimeout(timer);
}
}
// 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 >= ACCESS_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);
}
}
// Utility to check return status from a call to request.
responseOk(code) {
if (code === undefined) {
return false;
}
return (code >= 200) && (code < 300);
}
/**
* 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`, `device`, `login`, `self`, and `websocket`.
*/
// Return the appropriate URL to access various Access API endpoints.
getApiEndpoint(endpoint) {
let endpointSuffix;
let endpointPrefix = "/proxy/access/api/v2/";
switch (endpoint) {
case "bootstrap":
endpointSuffix = "devices/topology4";
break;
case "controller":
endpointSuffix = "access/info";
break;
case "device":
endpointSuffix = "device";
break;
case "location":
endpointSuffix = "location";
break;
case "login":
endpointPrefix = "/api/";
endpointSuffix = "auth/login";
break;
case "self":
endpointPrefix = "/api/";
endpointSuffix = "users/self";
break;
case "settings":
endpointSuffix = "settings";
break;
case "websocket":
endpointSuffix = "ws";
break;
default:
break;
}
if (!endpointSuffix) {
return "";
}
return "https://" + this.address + endpointPrefix + endpointSuffix;
}
/**
* Access the Access controller information JSON.
*
* @returns Returns the controller information JSON if the Access controller has been bootstrapped, `null` otherwise.
*/
// Get the controller JSON.
get controller() {
return this._controller;
}
/**
* Access the Access controller bootstrap JSON.
*
* @returns Returns the bootstrap JSON if the Access controller has been bootstrapped, `null` otherwise.
*/
// Get the bootstrap JSON.
get bootstrap() {
return this._bootstrap;
}
/**
* Access the Access controller list of devices.
*
* @returns Returns an array of all the devices from all the UniFi Access hubs associated with this controller, `null` otherwise.
*/
// Get the list of devices.
get devices() {
return this._devices;
}
/**
* Access the Access controller list of doors.
*
* @returns Returns an array of all the doors from all the UniFi Access hubs associated with this controller, `null` otherwise.
*/
// Get the list of doors.
get doors() {
return this._doors;
}
/**
* Access the Access controller list of floors.
*
* @returns Returns an array of all the floors from all the UniFi Access hubs associated with this controller, `null` otherwise.
*/
// Get the list of floors.
get floors() {
return this._floors;
}
/**
* Utility method that returns whether the credentials that were used to login to the Access controller have administrative privileges or not.
*
* @returns Returns `true` if the logged in user has administrative privileges, `false` otherwise.
*/
// Return whether the logged in credentials are an admin user or not.
get isAdminUser() {
return this._isAdminUser;
}
/**
* Utility method that returns whether our connection to the Access 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._isThrottled;
}
/**
* Utility method that returns a nicely formatted version of the Access controller name.
*
* @returns Returns the Access controller name in the following format:
* <code>*Access controller name* [*Access controller type*]</code>.
*/
// Utility to generate a nicely formatted controller string.
get name() {
// Our controller string, if it exists, appears as `Controller [Controller Type]`. Otherwise, we appear as `address`.
// Our controller string, if it exists, appears as `Controller`. Otherwise, we appear as `address`.
if (this._bootstrap?.alias?.length || this._bootstrap?.name?.length) {
return this._bootstrap.alias?.length ? this._bootstrap.alias : this._bootstrap.name ?? "Unknown";
}
else {
return this.address;
}
}
}
//# sourceMappingURL=access-api.js.map