344 lines
8.3 KiB
TypeScript
344 lines
8.3 KiB
TypeScript
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 || [];
|
|
}
|
|
}
|