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:
343
ts/classes.unifi-protect.ts
Normal file
343
ts/classes.unifi-protect.ts
Normal 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 || [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user