UNPKG

@switchbot/homebridge-switchbot

Version:

The SwitchBot plugin allows you to access your SwitchBot device(s) from HomeKit.

267 lines (240 loc) • 10 kB
import type { SwitchBotPluginConfig } from './settings.js' import type { SwitchBot } from 'node-switchbot' import { getDeviceCommandHandler } from './deviceCommandMapper.js' import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js' export interface ISwitchBotClient { init: () => Promise<void> getDevice: (id: string) => Promise<any> getDevices: () => Promise<any[]> setDeviceState: (id: string, body: any) => Promise<any> destroy: () => Promise<void> } /** * Thin wrapper around node-switchbot v4.0.0+ * Leverages upstream resilience features (retry, circuit breaker, connection intelligence) * while maintaining plugin-specific features like write debouncing and OpenAPI fallback. */ export class SwitchBotClient implements ISwitchBotClient { private cfg: SwitchBotPluginConfig private client: SwitchBot | null = null private writeDebounceMs = 100 private discoveryCacheTtlMs = 30_000 private lastDiscoveryAt = 0 private logger: import('homebridge').Logger private pendingWrites: Map<string, { timer: any, body: any, resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> }> = new Map() constructor(cfg: SwitchBotPluginConfig) { this.cfg = cfg this.logger = (cfg as any)?.logger as import('homebridge').Logger if (!this.logger) { throw new Error('SwitchBotClient requires a logger (Homebridge logger) in config') } if (typeof (cfg as any)?.writeDebounceMs === 'number') { this.writeDebounceMs = (cfg as any).writeDebounceMs } if (typeof (cfg as any)?.discoveryCacheTtlMs === 'number') { this.discoveryCacheTtlMs = Math.max(0, (cfg as any).discoveryCacheTtlMs) } } async init(): Promise<void> { if (this.client) { return } try { // Dynamic import of node-switchbot v4 with native resilience features const { SwitchBot } = await import('node-switchbot') const rawNodeClientConfig = typeof (this.cfg as any)?.nodeClientConfig === 'object' ? (this.cfg as any).nodeClientConfig : {} const scanTimeout = this.resolveScanTimeoutMs(rawNodeClientConfig) this.client = new SwitchBot({ token: this.cfg.openApiToken, secret: this.cfg.openApiSecret, // Enable built-in resilience features from node-switchbot v4. enableFallback: true, // Auto-fallback from BLE to API enableRetry: true, // Retry with exponential backoff enableCircuitBreaker: true, // Circuit breaker per connection type enableConnectionIntelligence: true, // Connection tracking and route preference enableBLE: this.cfg.enableBLE !== false, // Use config value, default true scanTimeout, ...rawNodeClientConfig, }) this.lastDiscoveryAt = 0 this.logger?.info?.('SwitchBot client initialized with native resilience features') } catch (e) { this.logger?.warn?.('Failed to load node-switchbot; will use OpenAPI fallback:', e) this.client = null } } async getDevice(id: string): Promise<any> { if (this.client) { try { const fromManager = this.getManagedDevice(id) if (fromManager) { return fromManager } const devices = await this.ensureDiscovered(false) const fromDiscovery = devices.find((d: any) => d.id === id) if (fromDiscovery) { return fromDiscovery } const refreshDevices = await this.ensureDiscovered(true) return refreshDevices.find((d: any) => d.id === id) } catch (e: any) { if (e instanceof SwitchbotAuthenticationError) { this.logger?.error?.(`Authentication error for getDevice(${id}):`, e.message) throw e } else if (e instanceof SwitchbotOperationError) { this.logger?.warn?.(`Operation error for getDevice(${id}):`, e.message, e.code) throw e } else if (e instanceof CharacteristicMissingError) { this.logger?.warn?.(`Characteristic missing for getDevice(${id}):`, e.characteristic) throw e } else { this.logger?.warn?.(`Client getDevice failed for ${id}:`, e) throw e } } } throw new SwitchbotOperationError('No SwitchBot client available', 'no_client') } async getDevices(): Promise<any[]> { if (this.client) { try { const fromManager = this.getManagedDevices() if (fromManager.length > 0) { return fromManager } return await this.ensureDiscovered(false) } catch (e) { this.logger?.warn?.('Client getDevices failed:', e) throw e } } throw new SwitchbotOperationError('No SwitchBot client available', 'no_client') } async setDeviceState(id: string, body: any): Promise<any> { // Plugin-level debounce: coalesce rapid writes per device if (!this.writeDebounceMs || this.writeDebounceMs <= 0) { return this._doSetDeviceState(id, body) } return new Promise((resolve, reject) => { const existing = this.pendingWrites.get(id) if (existing) { existing.body = body existing.resolvers.push({ resolve, reject }) return } const resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> = [{ resolve, reject }] const timer = setTimeout(async () => { const entry = this.pendingWrites.get(id) if (!entry) { return } this.pendingWrites.delete(id) try { const out = await this._doSetDeviceState(id, entry.body) for (const r of entry.resolvers) r.resolve(out) } catch (e: any) { if (e instanceof SwitchbotAuthenticationError) { this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message) } else if (e instanceof SwitchbotOperationError) { this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code) } else if (e instanceof CharacteristicMissingError) { this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic) } for (const r of entry.resolvers) r.reject(e) } }, this.writeDebounceMs) this.pendingWrites.set(id, { timer, body, resolvers }) }) } private async _doSetDeviceState(id: string, body: any): Promise<any> { if (!this.client) { throw new SwitchbotOperationError('No SwitchBot client available for setDeviceState', 'no_client') } try { const device = await this.getDevice(id) if (!device) { throw new SwitchbotOperationError(`Device ${id} not found`, 'device_not_found') } const deviceType = (device.deviceType ?? '').toLowerCase() const command = body?.command if (!command) { throw new SwitchbotOperationError('No command specified in body', 'no_command') } const handler = getDeviceCommandHandler(deviceType, command) if (!handler) { throw new SwitchbotOperationError(`Unsupported command '${command}' for device type '${deviceType}'`, 'unsupported_command') } this.logger?.debug?.(`[${id}] Calling mapped command '${command}' for device type '${deviceType}'`) return await handler(device, body) } catch (e: any) { if (e instanceof SwitchbotAuthenticationError) { this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message) throw e } else if (e instanceof SwitchbotOperationError) { this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code) throw e } else if (e instanceof CharacteristicMissingError) { this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic) throw e } else { this.logger?.warn?.(`Device command failed for ${id}:`, e) throw e } } } async destroy(): Promise<void> { for (const [, pending] of this.pendingWrites) { clearTimeout(pending.timer) const err = new SwitchbotOperationError('Client destroyed before pending write was sent', 'client_destroyed') for (const r of pending.resolvers) { r.reject(err) } } this.pendingWrites.clear() if (this.client?.cleanup) { await this.client.cleanup() } this.client = null this.lastDiscoveryAt = 0 } private resolveScanTimeoutMs(rawNodeClientConfig: Record<string, any>): number { if (typeof rawNodeClientConfig.scanTimeout === 'number' && Number.isFinite(rawNodeClientConfig.scanTimeout)) { return Math.max(500, rawNodeClientConfig.scanTimeout) } if (typeof rawNodeClientConfig.scanDuration === 'number' && Number.isFinite(rawNodeClientConfig.scanDuration)) { return Math.max(500, rawNodeClientConfig.scanDuration) } if (typeof (this.cfg as any)?.bleScanDurationSeconds === 'number') { return Math.max(500, (this.cfg as any).bleScanDurationSeconds * 1000) } return 5000 } private getManagedDevice(id: string): any { const manager = (this.client as any)?.devices if (manager?.get) { return manager.get(id) } return undefined } private getManagedDevices(): any[] { const manager = (this.client as any)?.devices if (manager?.list) { const list = manager.list() return Array.isArray(list) ? list : [] } return [] } private async ensureDiscovered(force: boolean): Promise<any[]> { if (!this.client) { throw new SwitchbotOperationError('No SwitchBot client available', 'no_client') } const fromManager = this.getManagedDevices() const cacheValid = this.discoveryCacheTtlMs > 0 && (Date.now() - this.lastDiscoveryAt) < this.discoveryCacheTtlMs if (!force && cacheValid && fromManager.length > 0) { return fromManager } const discovered = await this.client.discover() this.lastDiscoveryAt = Date.now() return discovered } }