feat(unifi): implement comprehensive UniFi API client with controllers, protect, access, account, managers, resources, HTTP client, interfaces, logging, plugins, and tests

This commit is contained in:
2026-02-02 15:46:41 +00:00
parent aaa9e67835
commit 740b70cd83
38 changed files with 6275 additions and 15 deletions

343
ts/classes.unifi-protect.ts Normal file
View File

@@ -0,0 +1,343 @@
import * as plugins from './plugins.js';
import { logger } from './unifi.logger.js';
import { UnifiHttp } from './classes.unifihttp.js';
import { CameraManager } from './classes.cameramanager.js';
import type {
IUnifiProtectOptions,
IProtectBootstrap,
IProtectCamera,
IProtectNvr,
THttpMethod,
} from './interfaces/index.js';
/**
* UniFi Protect - Entry point for Protect NVR API
*
* This class provides access to the UniFi Protect API for managing cameras,
* recordings, and motion events. Supports two authentication methods:
* 1. API Key (preferred) - Set X-API-Key header, no login required
* 2. Session auth - Username/password login with session cookies + CSRF
*
* @example
* ```typescript
* // Using API key (no login required)
* const protect = new UnifiProtect({
* host: '192.168.1.1',
* apiKey: 'your-api-key',
* });
* await protect.refreshBootstrap(); // Load camera data
* const cameras = await protect.cameraManager.listCameras();
*
* // Using session auth
* const protect = new UnifiProtect({
* host: '192.168.1.1',
* username: 'admin',
* password: 'password',
* });
* await protect.login();
* const cameras = await protect.cameraManager.listCameras();
* await protect.logout();
* ```
*/
export class UnifiProtect {
/** Protect host */
private host: string;
/** API key for authentication */
private apiKey?: string;
/** Username for session authentication */
private username?: string;
/** Password for session authentication */
private password?: string;
/** Whether to verify SSL certificates */
private verifySsl: boolean;
/** HTTP client for Protect API */
private http: UnifiHttp;
/** HTTP client for auth (console level) */
private authHttp: UnifiHttp;
/** Whether currently authenticated */
private authenticated: boolean = false;
/** CSRF token */
private csrfToken?: string;
/** Bootstrap data (contains all cameras, NVR info, etc.) */
private bootstrap?: IProtectBootstrap;
/** Camera manager instance */
public cameraManager: CameraManager;
constructor(options: IUnifiProtectOptions) {
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.username = options.username;
this.password = options.password;
this.verifySsl = options.verifySsl ?? false;
// Build base URLs
const baseHost = this.host.startsWith('http') ? this.host : `https://${this.host}`;
// Auth happens at console level
this.authHttp = new UnifiHttp(baseHost, this.verifySsl);
// Protect API is behind /proxy/protect/api
this.http = new UnifiHttp(`${baseHost}/proxy/protect/api`, this.verifySsl);
// If API key provided, set it and mark as authenticated
if (this.apiKey) {
this.http.setHeader('X-API-Key', this.apiKey);
this.authenticated = true;
}
// Initialize managers
this.cameraManager = new CameraManager(this);
logger.log('info', `UnifiProtect initialized for ${this.host}`);
}
/**
* Login to Protect (only needed for session auth, not API key)
*/
public async login(): Promise<void> {
// If using API key, already authenticated
if (this.apiKey) {
logger.log('info', 'Using API key authentication, no login required');
this.authenticated = true;
await this.fetchBootstrap();
return;
}
if (!this.username || !this.password) {
throw new Error('Username and password required for session authentication');
}
logger.log('info', `Logging in to UniFi Protect at ${this.host}`);
// Login at console level
const response = await this.authHttp.rawRequest('POST', '/api/auth/login', {
username: this.username,
password: this.password,
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Protect login failed: HTTP ${response.status} - ${errorText}`);
}
// Store cookies
const setCookieHeaders = response.headers?.['set-cookie'];
if (setCookieHeaders && Array.isArray(setCookieHeaders)) {
this.authHttp.setCookies(setCookieHeaders);
this.http.setCookies(setCookieHeaders);
}
// Extract CSRF token
const csrfHeader = response.headers?.['x-csrf-token'];
if (csrfHeader) {
this.csrfToken = Array.isArray(csrfHeader) ? csrfHeader[0] : csrfHeader;
this.http.setHeader('X-CSRF-Token', this.csrfToken);
}
this.authenticated = true;
// Fetch bootstrap to get cameras and NVR info
await this.fetchBootstrap();
logger.log('info', 'Successfully logged in to UniFi Protect');
}
/**
* Logout from Protect (only for session auth)
*/
public async logout(): Promise<void> {
// If using API key, nothing to logout
if (this.apiKey) {
return;
}
if (!this.authenticated) {
return;
}
logger.log('info', 'Logging out from UniFi Protect');
try {
await this.authHttp.rawRequest('POST', '/api/auth/logout');
} catch (error) {
logger.log('warn', `Protect logout error (may be expected): ${error}`);
}
this.authenticated = false;
this.csrfToken = undefined;
this.bootstrap = undefined;
}
/**
* Check if logged in
*/
public isAuthenticated(): boolean {
return this.authenticated;
}
/**
* Fetch bootstrap data (cameras, NVR info, etc.)
*/
private async fetchBootstrap(): Promise<void> {
logger.log('debug', 'Fetching Protect bootstrap');
this.bootstrap = await this.http.request<IProtectBootstrap>('GET', '/bootstrap');
logger.log('info', `Bootstrap loaded: ${this.bootstrap.cameras?.length || 0} cameras`);
}
/**
* Refresh bootstrap data
*/
public async refreshBootstrap(): Promise<void> {
await this.fetchBootstrap();
}
/**
* Get NVR info from bootstrap
*/
public getNvrInfo(): IProtectNvr | undefined {
return this.bootstrap?.nvr;
}
/**
* Get cameras from bootstrap (cached)
*/
public getCamerasFromBootstrap(): IProtectCamera[] {
return this.bootstrap?.cameras || [];
}
/**
* Make a request to the Protect API
*/
public async request<T>(
method: THttpMethod,
endpoint: string,
data?: unknown
): Promise<T> {
if (!this.authenticated) {
throw new Error('Not authenticated. Call login() first or provide API key.');
}
return this.http.request<T>(method, endpoint, data);
}
/**
* Get all cameras (convenience method)
*/
public async getCameras() {
return this.cameraManager.listCameras();
}
/**
* Get storage info from NVR
*/
public getStorageInfo(): { totalSize?: number; usedSpace?: number; freeSpace?: number } | undefined {
const nvr = this.getNvrInfo();
if (!nvr?.storageInfo) return undefined;
const totalSize = nvr.storageInfo.totalSize;
const usedSpace = nvr.storageInfo.totalSpaceUsed;
const freeSpace = totalSize && usedSpace ? totalSize - usedSpace : undefined;
return {
totalSize,
usedSpace,
freeSpace,
};
}
/**
* Get NVR uptime
*/
public getNvrUptime(): number | undefined {
return this.getNvrInfo()?.uptime;
}
/**
* Check if NVR is connected to cloud
*/
public isCloudConnected(): boolean {
return this.getNvrInfo()?.isConnectedToCloud === true;
}
/**
* Get system events
*/
public async getSystemEvents(limit: number = 100): Promise<unknown> {
return this.request('GET', `/events?limit=${limit}`);
}
/**
* Get liveviews
*/
public async getLiveviews(): Promise<unknown> {
return this.request('GET', '/liveviews');
}
/**
* Get users
*/
public async getUsers(): Promise<unknown> {
return this.request('GET', '/users');
}
/**
* Get groups
*/
public async getGroups(): Promise<unknown> {
return this.request('GET', '/groups');
}
/**
* Get lights (if any)
*/
public getLights() {
return this.bootstrap?.lights || [];
}
/**
* Get sensors (if any)
*/
public getSensors() {
return this.bootstrap?.sensors || [];
}
/**
* Get doorbells
*/
public getDoorbells() {
return this.bootstrap?.doorbells || [];
}
/**
* Get chimes
*/
public getChimes() {
return this.bootstrap?.chimes || [];
}
/**
* Get bridges
*/
public getBridges() {
return this.bootstrap?.bridges || [];
}
/**
* Get viewers
*/
public getViewers() {
return this.bootstrap?.viewers || [];
}
}