UNPKG

@switchbot/homebridge-switchbot

Version:

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

1,224 lines (1,150 loc) 53 kB
// Utility: Validate BLE response length before parsing /* eslint-disable style/max-statements-per-line, unused-imports/no-unused-vars */ /** * Status Update Strategy for BLE and OpenAPI * * BLE (Bluetooth Low Energy): * - Primary: Subscribes to device notifications for real-time state updates using _subscribeBLENotifications(). * - Fallback: (Recommended) Optionally, a low-frequency polling timer (e.g., every 5–10 minutes) can call getState() to recover from missed notifications or connection loss. * - This ensures state stays in sync even if notifications are unreliable or the device reconnects. * - Polling should be infrequent to avoid battery drain and BLE congestion. * * BLE Polling Options (config & per-device): * - blePollingEnabled (boolean): Enable/disable BLE polling fallback (default: true). * - blePollIntervalMs (integer): Polling interval in ms (default: 600000, min: 60000). * - These can be set globally in config or overridden per device. * - Setting a lower interval increases update frequency but may drain battery faster. * - Setting a higher interval reduces battery impact but may delay state recovery. * * OpenAPI (Cloud): * - Uses periodic polling to fetch device status at a configurable interval (default: 300 seconds, can be set per device or platform). * - Platform supports batched refresh (matterBatchEnabled, matterBatchRefreshRate, etc.) and per-device refreshRate overrides. * - Rate limiting: * - Default daily limit: 10,000 OpenAPI requests (configurable via options.dailyApiLimit). * - Reserve: 1,000 requests for user commands (options.dailyApiReserveForCommands). * - When the remaining budget reaches the reserve, background polling/discovery pauses, but user commands and webhooks continue. * - Counter resets at local or UTC midnight (options.dailyApiResetLocalMidnight). * * Best Practices: * - BLE: Use notifications for instant updates, add periodic polling as a safety net. * - OpenAPI: Tune polling intervals to balance freshness and rate limit budget. * - Both: Document and expose polling intervals and rate limit settings in config. * * See README.md and docs for more details. */ import type { SwitchBotPluginConfig } from '../settings.js' import { Buffer } from 'node:buffer' import { MATTER_ATTRIBUTE_IDS, MATTER_CLUSTER_IDS } from '../utils.js' import { DeviceBase } from './deviceBase.js' function validateBLEResponseLength(buf: Buffer | Uint8Array | any[], expected: number, context = '', log: import('homebridge').Logger): boolean { if (!buf || typeof buf.length !== 'number' || buf.length !== expected) { log.warn(`[BLE] Invalid response length${context ? ` for ${context}` : ''}: expected ${expected}, got ${buf?.length}`) return false } return true } // BLE notification handling: per-command notification futures and unsolicited notification logging const BLE_NOTIFICATION_HANDLERS = new Map<string, (payload: Buffer) => void>() // Module-scope regex pattern to avoid recompilation const HEX_COLOR_REGEX = /^#?[0-9A-F]{6}$/i export class GenericDevice extends DeviceBase { protected log: import('homebridge').Logger private _blePollTimer: NodeJS.Timeout | null = null private _blePollIntervalMs: number private _blePollingEnabled: boolean constructor(opts: any, cfg: SwitchBotPluginConfig) { super(opts, cfg) // Require logger from opts or cfg this.log = opts?.log || cfg?.log if (!this.log) { throw new Error('Device requires a logger (Homebridge logger) in opts or cfg') } // If BLE encryptionKey/keyId provided, set on node-switchbot device instance if possible if (opts.encryptionKey && this.client && typeof this.client.devices?.get === 'function') { try { const dev = this.client.devices.get(opts.id) if (dev && typeof dev.setKey === 'function') { dev.setKey({ encryptionKey: opts.encryptionKey, keyId: opts.keyId || undefined, }) } } catch (e) { // ignore if device not found or setKey not available } } // BLE polling config: allow override via opts.blePollingEnabled/blePollIntervalMs or cfg.blePollingEnabled/blePollIntervalMs this._blePollingEnabled = opts?.blePollingEnabled ?? cfg?.blePollingEnabled ?? true let pollMs = opts?.blePollIntervalMs ?? cfg?.blePollIntervalMs ?? 10 * 60 * 1000 // default: 10 min if (typeof pollMs !== 'number' || Number.isNaN(pollMs) || pollMs < 60000) { this.log.warn(`[BLE] Invalid blePollIntervalMs (${pollMs}), using minimum 60000ms`) pollMs = 60000 } this._blePollIntervalMs = pollMs // Subscribe to BLE notifications if supported (node-switchbot v4+) this._subscribeBLENotifications() // Start BLE polling fallback if enabled if (this._blePollingEnabled) { this._startBlePolling() } } /** * Start periodic BLE polling as a fallback to notifications. */ private _startBlePolling() { if (this._blePollTimer) { clearInterval(this._blePollTimer) } this._blePollTimer = setInterval(async () => { try { this.log.debug(`[BLE] Polling getState() for device ${this.opts.id}`) await this.getState() } catch (e) { this.log.debug(`[BLE] Polling getState() failed for device ${this.opts.id}:`, (e as Error)?.message) } }, this._blePollIntervalMs) } /** * Clean up BLE polling timer on destroy. */ async destroy(): Promise<void> { if (this._blePollTimer) { clearInterval(this._blePollTimer) this._blePollTimer = null } // Only call super.destroy if DeviceBase.prototype.destroy is a function and not this method itself const baseProto = Object.getPrototypeOf(GenericDevice.prototype) if (typeof baseProto.destroy === 'function' && baseProto.destroy !== GenericDevice.prototype.destroy) { await super.destroy() } } /** * Subscribe to BLE notifications for this device (if supported by node-switchbot) * Logs unsolicited notifications and enables per-command notification futures. */ private async _subscribeBLENotifications() { if (!this.client || typeof this.client.devices?.get !== 'function') { return } const dev = this.client.devices.get(this.opts.id) if (!dev || typeof dev.mac !== 'string' || !dev.mac) { return } // Only subscribe once per device if (BLE_NOTIFICATION_HANDLERS.has(dev.mac)) { return } if (typeof dev.subscribeNotifications === 'function') { const handler = (payload: Buffer) => { // If a per-command notification future is waiting, let node-switchbot handle it // Otherwise, log unsolicited notification if (payload && payload.length > 0) { // Unsolicited notification logging // (node-switchbot will resolve per-command futures internally) this.log.debug(`[BLE] Unsolicited notification from ${dev.mac}: ${payload.toString('hex')}`) } } try { // Subscribe and remember handler for possible cleanup await dev.subscribeNotifications(handler) BLE_NOTIFICATION_HANDLERS.set(dev.mac, handler) } catch (e) { // ignore if subscription fails } } } /** * Await a BLE notification for this device (for advanced use in subclasses) * Returns the notification payload or throws on timeout. */ protected async _awaitBLENotification(timeoutMs = 5000): Promise<Buffer> { if (!this.client || typeof this.client.devices?.get !== 'function') { throw new Error('No BLE client/device') } const dev = this.client.devices.get(this.opts.id) if (!dev || typeof dev.mac !== 'string' || !dev.mac) { throw new Error('No BLE MAC for device') } if (typeof dev.bleConnection?.sendCommand !== 'function') { throw new TypeError('BLE connection does not support sendCommand') } // This is a low-level utility; in most cases, node-switchbot handles notification futures for commands // Here, we expose a direct await for advanced use return new Promise<Buffer>((resolve, reject) => { let timer: NodeJS.Timeout | undefined const handler = (payload: Buffer) => { clearTimeout(timer) dev.bleConnection?.unsubscribeNotifications(dev.mac, handler) resolve(payload) } dev.bleConnection?.subscribeNotifications(dev.mac, handler).then(() => { timer = setTimeout(() => { dev.bleConnection?.unsubscribeNotifications(dev.mac, handler) reject(new Error('BLE notification timeout')) }, timeoutMs) }).catch(reject) }) } async getState(): Promise<any> { // Default: return minimal info; implementations should override if (this.client && typeof this.client.getDevice === 'function') { try { const raw = await this.client.getDevice(this.opts.id) // If this is a BLE buffer/array, validate length (common BLE status: 12 bytes, but may vary by device) if (raw && (raw instanceof Buffer || Array.isArray(raw) || raw instanceof Uint8Array)) { // Default to 12, override per device if needed if (!validateBLEResponseLength(raw, 12, this.opts.type, this.log)) { return { id: this.opts.id, type: this.opts.type, error: 'invalid_ble_response_length', raw } } } // Normalize common response shapes try { const device = raw?.body ?? raw return device } catch (e) { return raw } } catch (e) { // ignore and fallback } } return { id: this.opts.id, type: this.opts.type } } async setState(change: any): Promise<any> { // Apply change via SwitchBot API in real implementation // Translate common high-level changes into SwitchBot OpenAPI commands if (!this.client) { return { success: false, reason: 'no client', change } } const cmdBody: any = {} if (typeof change.on === 'boolean') { cmdBody.command = change.on ? 'turnOn' : 'turnOff' cmdBody.parameter = 'default' cmdBody.commandType = 'command' } else if (typeof change.brightness === 'number') { const v = Math.max(0, Math.min(100, Number(change.brightness))) cmdBody.command = 'setBrightness' cmdBody.parameter = String(v) cmdBody.commandType = 'command' } else if (typeof change.speed === 'number') { const v = Math.max(0, Math.min(100, Number(change.speed))) cmdBody.command = 'setFanSpeed' cmdBody.parameter = String(v) cmdBody.commandType = 'command' } else if (typeof change.position === 'number') { const v = Math.max(0, Math.min(100, Number(change.position))) cmdBody.command = 'setPosition' cmdBody.parameter = String(v) cmdBody.commandType = 'command' } else if (typeof change.locked === 'boolean') { cmdBody.command = change.locked ? 'lock' : 'unlock' cmdBody.parameter = 'default' cmdBody.commandType = 'command' } else if (typeof change.start === 'boolean') { cmdBody.command = change.start ? 'start' : 'stop' cmdBody.parameter = 'default' cmdBody.commandType = 'command' } else { // If caller supplied an explicit command body, pass through if (change && typeof change.command === 'string') { Object.assign(cmdBody, change) } else { // Fallback: send raw change to client setDeviceState try { if (typeof this.client.setDeviceState === 'function') { return await this.client.setDeviceState(this.opts.id, change) } if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, change) } } catch (err) { const e = err as any return { success: false, reason: e?.message ?? String(e) } } return { success: false, reason: 'unsupported change', change } } } try { return await this.client.setDeviceState(this.opts.id, cmdBody) } catch (err) { // try alternative client API if available try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, cmdBody) } } catch (e2) { // ignore } const e = err as any return { success: false, reason: e?.message ?? String(e) } } } createHAPAccessory(api: any): any { // Default HAP descriptor: a Switch service with On characteristic return { services: [ { type: 'Switch', characteristics: { On: { get: async () => { const s = await this.getState() return !!(s && (s.on === true || s.state === 'on' || s.power === 'on')) }, set: async (v: any) => { await this.setState({ on: !!v }) }, }, }, }, ], } } // Default Matter descriptor mirrors HAP descriptor structure so the // platform can construct a Matter accessory representation when // Homebridge Matter APIs are available. Device subclasses may override // this to provide Matter-specific clusters/attributes if desired. async createMatterAccessory(api: any): Promise<any> { // Dynamically detect features from getState() const state = await this.getState() const clusters: any[] = [] // On/Off (Switch/Plug/Generic) if ('on' in state || 'power' in state || 'state' in state) { clusters.push({ type: 'OnOff', clusterId: MATTER_CLUSTER_IDS.OnOff, attributes: { onOff: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v: any) => this.setState({ on: !!v }) }, [MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v: any) => this.setState({ on: !!v }) }, }, }) } // Brightness (Light) if ('brightness' in state) { clusters.push({ type: 'LevelControl', clusterId: MATTER_CLUSTER_IDS.LevelControl, attributes: { currentLevel: { read: async () => (await this.getState()).brightness ?? 100, write: async (v: any) => this.setState({ brightness: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.LevelControl.CurrentLevel]: { read: async () => (await this.getState()).brightness ?? 100, write: async (v: any) => this.setState({ brightness: Number(v) }) }, }, }) } // Color (Light) if ('hue' in state && 'saturation' in state) { clusters.push({ type: 'ColorControl', clusterId: MATTER_CLUSTER_IDS.ColorControl, attributes: { colorMode: { read: async () => 0 }, colorHue: { read: async () => (await this.getState()).hue ?? 0, write: async (v: any) => this.setState({ hue: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => (await this.getState()).hue ?? 0, write: async (v: any) => this.setState({ hue: Number(v) }) }, colorSaturation: { read: async () => (await this.getState()).saturation ?? 0, write: async (v: any) => this.setState({ saturation: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentSaturation]: { read: async () => (await this.getState()).saturation ?? 0, write: async (v: any) => this.setState({ saturation: Number(v) }) }, ...(typeof state.colorTemperature === 'number' || typeof state.kelvin === 'number' ? { colorTemperature: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v: any) => this.setState({ colorTemperature: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.ColorTemperatureMireds]: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v: any) => this.setState({ colorTemperature: Number(v) }) }, } : {}), }, }) } // Temperature sensor if ('temperature' in state) { clusters.push({ type: 'TemperatureMeasurement', // No clusterId, not present in MATTER_CLUSTER_IDS attributes: { measuredValue: { read: async () => (await this.getState()).temperature ?? 0 }, }, }) } // Humidity sensor if ('humidity' in state) { clusters.push({ type: 'RelativeHumidityMeasurement', clusterId: MATTER_CLUSTER_IDS.RelativeHumidityMeasurement, attributes: { measuredValue: { read: async () => (await this.getState()).humidity ?? 0 }, }, }) } // CO2 sensor if ('CO2' in state) { clusters.push({ type: 'AirQuality', attributes: { CO2: { read: async () => (await this.getState()).CO2 ?? 0 }, }, }) } // Lock if ('lockState' in state || 'locked' in state) { clusters.push({ type: 'DoorLock', clusterId: MATTER_CLUSTER_IDS.DoorLock, attributes: { lockState: { read: async () => (await this.getState()).lockState ?? (await this.getState()).locked ? 1 : 0, write: async (v: any) => this.setState({ locked: !!v }) }, }, }) } // Motion sensor if ('moveDetected' in state || 'motion' in state) { clusters.push({ type: 'OccupancySensing', // No clusterId, not present in MATTER_CLUSTER_IDS attributes: { occupancy: { read: async () => (await this.getState()).moveDetected === true || (await this.getState()).motion === true ? 1 : 0 }, }, }) } // Contact sensor if ('openState' in state || 'contact' in state || 'open' in state) { clusters.push({ type: 'BooleanState', // No clusterId, not present in MATTER_CLUSTER_IDS attributes: { stateValue: { read: async () => (await this.getState()).openState === 'open' || (await this.getState()).open === true ? 1 : 0 }, }, }) } // Leak sensor if ('leak' in state || 'status' in state) { clusters.push({ type: 'LeakSensor', attributes: { leakDetected: { read: async () => (await this.getState()).leak === true || (await this.getState()).status === 1 ? 1 : 0 }, }, }) } // Energy monitoring (Plug) if ('voltage' in state || 'power' in state || 'electricCurrent' in state) { clusters.push({ type: 'ElectricalMeasurement', // No clusterId, not present in MATTER_CLUSTER_IDS attributes: { voltage: { read: async () => (await this.getState()).voltage ?? 0 }, power: { read: async () => (await this.getState()).power ?? 0 }, electricCurrent: { read: async () => (await this.getState()).electricCurrent ?? 0 }, }, }) } // Fan if ('speed' in state || 'fanSpeed' in state) { clusters.push({ type: 'FanControl', clusterId: MATTER_CLUSTER_IDS.FanControl, attributes: { speedCurrent: { read: async () => (await this.getState()).speed ?? (await this.getState()).fanSpeed ?? 0, write: async (v: any) => this.setState({ speed: Number(v) }) }, }, }) } // Vacuum if ('workingStatus' in state) { clusters.push({ type: 'RobotVacuumCleaner', attributes: { workingStatus: { read: async () => (await this.getState()).workingStatus ?? 'StandBy' }, }, }) } return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters, } } } // Specific device classes can extend GenericDevice for custom behavior. export class BotDevice extends GenericDevice {} export class CurtainDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'WindowCovering', characteristics: { CurrentPosition: { get: async () => { const s = await this.getState() return typeof s.position === 'number' ? s.position : 0 }, }, TargetPosition: { get: async () => { const s = await this.getState() return typeof s.position === 'number' ? s.position : 0 }, set: async (v: any) => { await this.setState({ position: Number(v) }) }, }, }, }, ], } } // Matter-specific descriptor for Curtain (WindowCovering cluster) with new attributes async createMatterAccessory(api: any): Promise<any> { // Get current state for dynamic attributes const state = await this.getState() // Compose attributes for Matter WindowCovering cluster const attributes: Record<string, any> = { currentPositionLiftPercent100ths: { read: async () => { const s = await this.getState() return typeof s.position === 'number' ? Math.round(s.position * 100) : 0 }, write: undefined, }, targetPositionLiftPercent100ths: { read: async () => { const s = await this.getState() return typeof s.position === 'number' ? Math.round(s.position * 100) : 0 }, write: async (v: any) => this.setState({ position: Math.round(Number(v) / 100) }), }, operationalStatus: { read: async () => state.operationalStatus ?? { global: 0, lift: 0, tilt: 0 }, write: undefined, }, endProductType: { read: async () => state.endProductType ?? 0, write: undefined, }, configStatus: { read: async () => state.configStatus ?? { operational: true, onlineReserved: true, liftMovementReversed: false, liftPositionAware: true, tiltPositionAware: false, liftEncoderControlled: true, tiltEncoderControlled: false, }, write: undefined, }, } // If tilt is supported, add tilt attributes if (typeof state.tilt === 'number') { attributes.currentPositionTiltPercent100ths = { read: async () => { const s = await this.getState() return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0 }, write: undefined, } attributes.targetPositionTiltPercent100ths = { read: async () => { const s = await this.getState() return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0 }, write: async (v: any) => this.setState({ tilt: Math.round(Number(v) / 100) }), } } const windowCoveringCluster = { type: 'WindowCovering', clusterId: MATTER_CLUSTER_IDS.WindowCovering, attributes, } // Provide both array and named property for clusters for compatibility with test expectations const clustersArr: any[] = [windowCoveringCluster] const clusters: any = [...clustersArr] // Always set clusters.windowCovering to the WindowCovering cluster by clusterId const foundWC = clustersArr.find((c: any) => c && c.clusterId === MATTER_CLUSTER_IDS.WindowCovering) clusters.windowCovering = foundWC || null return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters, } } } export class FanDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'Fan', characteristics: { On: { get: async () => { const s = await this.getState() return !!(s && (s.on === true || s.state === 'on')) }, set: async (v: any) => { await this.setState({ on: !!v }) }, }, RotationSpeed: { get: async () => { const s = await this.getState() return typeof s.speed === 'number' ? s.speed : 0 }, set: async (v: any) => { await this.setState({ speed: Number(v) }) }, }, }, }, ], } } async setState(change: any): Promise<any> { if (!this.client) { return { success: false, reason: 'no client' } } // Oscillation support if (typeof change.oscillate === 'boolean') { const body = { command: 'setOscillation', parameter: change.oscillate ? 'on' : 'off', commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } // Swing / sweep support (angle or mode) if (change && (typeof change.swing === 'boolean' || typeof change.swingAngle === 'number' || typeof change.swingMode === 'string')) { let param: string = 'default' if (typeof change.swingMode === 'string') { param = change.swingMode } else if (typeof change.swingAngle === 'number') { param = String(Number(change.swingAngle)) } else { param = change.swing ? 'on' : 'off' } const body = { command: 'setSwing', parameter: param, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } return super.setState(change) } // Matter-specific descriptor for Fan createMatterAccessory(api: any): any { return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters: [ { // OnOff cluster type: 'OnOff', clusterId: MATTER_CLUSTER_IDS.OnOff, attributes: { onOff: { read: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on')) }, write: async (v: any) => this.setState({ on: !!v }) }, // numeric attribute id for onOff [MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on')) }, write: async (v: any) => this.setState({ on: !!v }) }, }, }, { // Fan Control cluster type: 'FanControl', clusterId: MATTER_CLUSTER_IDS.FanControl, attributes: { rotationSpeed: { read: async () => { const s = await this.getState(); return typeof s.speed === 'number' ? s.speed : 0 }, write: async (v: any) => this.setState({ speed: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.FanControl.SpeedCurrent]: { read: async () => { const s = await this.getState(); return typeof s.speed === 'number' ? s.speed : 0 }, write: async (v: any) => this.setState({ speed: Number(v) }) }, oscillation: { read: async () => { const s = await this.getState(); return !!s?.oscillating }, write: async (v: any) => this.setState({ oscillate: !!v }) }, swingMode: { read: async () => { const s = await this.getState(); return s?.swingMode ?? null }, write: async (v: any) => this.setState({ swingMode: v }) }, }, }, ], } } } export class LightDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'Lightbulb', characteristics: { On: { get: async () => { const s = await this.getState() return !!(s && (s.on === true || s.state === 'on' || s.power === 'on')) }, set: async (v: any) => { await this.setState({ on: !!v }) }, }, Brightness: { props: { minValue: 0, maxValue: 100, minStep: 1 }, get: async () => { const s = await this.getState() return typeof s.brightness === 'number' ? s.brightness : 100 }, set: async (v: any) => { await this.setState({ brightness: Number(v) }) }, }, Hue: { props: { minValue: 0, maxValue: 360, minStep: 1 }, get: async () => { const s = await this.getState() // prefer explicit hue if provided if (s && typeof s.hue === 'number') { return s.hue } // try HSV from color hex const hex = s?.color || s?.colorHex || s?.colour if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) { const h = (() => { const hsl = (h: number, s: number, l: number) => ({ h, s, l }) // convert hex -> rgb -> hsv const cleaned = hex.replace('#', '') const r = Number.parseInt(cleaned.substr(0, 2), 16) / 255 const g = Number.parseInt(cleaned.substr(2, 2), 16) / 255 const b = Number.parseInt(cleaned.substr(4, 2), 16) / 255 const mx = Math.max(r, g, b); const mn = Math.min(r, g, b) const d = mx - mn if (d === 0) { return 0 } let hue = 0 switch (mx) { case r: hue = ((g - b) / d) % 6; break case g: hue = (b - r) / d + 2; break case b: hue = (r - g) / d + 4; break } hue = Math.round(hue * 60) if (hue < 0) { hue += 360 } return hue })() return h } return 0 }, set: async (v: any) => { await this.setState({ hue: Number(v) }) }, }, Saturation: { props: { minValue: 0, maxValue: 100, minStep: 1 }, get: async () => { const s = await this.getState() if (s && typeof s.saturation === 'number') { return s.saturation } // if color hex is available, derive saturation from rgb const hex = s?.color || s?.colorHex || s?.colour if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) { const cleaned = hex.replace('#', '') const r = Number.parseInt(cleaned.substr(0, 2), 16) / 255 const g = Number.parseInt(cleaned.substr(2, 2), 16) / 255 const b = Number.parseInt(cleaned.substr(4, 2), 16) / 255 const mx = Math.max(r, g, b); const mn = Math.min(r, g, b) const d = mx - mn const sat = mx === 0 ? 0 : Math.round((d / mx) * 100) return sat } return 0 }, set: async (v: any) => { await this.setState({ saturation: Number(v) }) }, }, ColorTemperature: { props: { minValue: 153, maxValue: 500, minStep: 1 }, get: async () => { const s = await this.getState() // prefer mired if provided if (s && typeof s.colorTemperature === 'number') { return s.colorTemperature } if (s && typeof s.color_temp === 'number') { return s.color_temp } // some devices provide kelvin if (s && typeof s.kelvin === 'number' && s.kelvin > 0) { return Math.round(1000000 / s.kelvin) } return 400 }, set: async (v: any) => { await this.setState({ colorTemperature: Number(v) }) }, }, }, }, ], } } async setState(change: any): Promise<any> { if (!this.client) { return { success: false, reason: 'no client' } } // Color temperature (mired) or brightness/hue/sat if (typeof change.colorTemperature === 'number' || typeof change.color_temp === 'number') { const v = String(Number(change.colorTemperature ?? change.color_temp)) const body = { command: 'setColorTemperature', parameter: v, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } if (typeof change.hue === 'number' && typeof change.saturation === 'number') { const body = { command: 'setColor', parameter: `${Number(change.hue)},${Number(change.saturation)}`, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } if (change && typeof change.color === 'string') { const body = { command: 'setColor', parameter: change.color, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } // Fallback to generic handler (brightness/on) return super.setState(change) } // Matter-specific descriptor for lights (OnOff + Level + Color) createMatterAccessory(api: any): any { return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters: [ { // OnOff cluster type: 'OnOff', clusterId: MATTER_CLUSTER_IDS.OnOff, attributes: { onOff: { read: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on' || s.power === 'on')) }, write: async (v: any) => this.setState({ on: !!v }) }, [MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on' || s.power === 'on')) }, write: async (v: any) => this.setState({ on: !!v }) }, }, }, { // Level Control cluster type: 'LevelControl', clusterId: MATTER_CLUSTER_IDS.LevelControl, attributes: { currentLevel: { read: async () => { const s = await this.getState(); return typeof s.brightness === 'number' ? s.brightness : 100 }, write: async (v: any) => this.setState({ brightness: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.LevelControl.CurrentLevel]: { read: async () => { const s = await this.getState(); return typeof s.brightness === 'number' ? s.brightness : 100 }, write: async (v: any) => this.setState({ brightness: Number(v) }) }, }, }, { // Color Control cluster type: 'ColorControl', clusterId: MATTER_CLUSTER_IDS.ColorControl, attributes: { // Required colorMode attribute for Matter conformance (0 = currentHueAndSaturation) colorMode: { read: async () => 0 }, colorHue: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0 }, write: async (v: any) => this.setState({ hue: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0 }, write: async (v: any) => this.setState({ hue: Number(v) }) }, colorSaturation: { read: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0 }, write: async (v: any) => this.setState({ saturation: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentSaturation]: { read: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0 }, write: async (v: any) => this.setState({ saturation: Number(v) }) }, colorTemperature: { read: async () => { const s = await this.getState(); if (typeof s.colorTemperature === 'number') { return s.colorTemperature } if (typeof s.kelvin === 'number') { return Math.round(1000000 / s.kelvin) } return 400 }, write: async (v: any) => this.setState({ colorTemperature: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.ColorTemperatureMireds]: { read: async () => { const s = await this.getState(); if (typeof s.colorTemperature === 'number') { return s.colorTemperature } if (typeof s.kelvin === 'number') { return Math.round(1000000 / s.kelvin) } return 400 }, write: async (v: any) => this.setState({ colorTemperature: Number(v) }) }, }, }, ], } } } export class LightStripDevice extends LightDevice {} export class MotionSensorDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'MotionSensor', characteristics: { MotionDetected: { get: async () => { const s = await this.getState() return !!(s && s.motion === true) }, }, }, }, ], } } async setState(change: any): Promise<any> { if (!this.client) { return { success: false, reason: 'no client' } } // Oscillation support if (typeof change.oscillate === 'boolean') { const body = { command: 'setOscillation', parameter: change.oscillate ? 'on' : 'off', commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } // Swing / sweep support (angle or mode) if (change && (typeof change.swing === 'boolean' || typeof change.swingAngle === 'number' || typeof change.swingMode === 'string')) { let param: string = 'default' if (typeof change.swingMode === 'string') { param = change.swingMode } else if (typeof change.swingAngle === 'number') { param = String(Number(change.swingAngle)) } else { param = change.swing ? 'on' : 'off' } const body = { command: 'setSwing', parameter: param, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } return super.setState(change) } } export class ContactSensorDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'ContactSensor', characteristics: { ContactSensorState: { get: async () => { const s = await this.getState() return s && s.open ? 1 : 0 }, }, }, }, ], } } } export class VacuumDevice extends GenericDevice { // Use DeviceBase defaults (Switch-style) — no override needed createHAPAccessory(api: any) { return super.createHAPAccessory(api) } } export class LockDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'LockMechanism', characteristics: { LockCurrentState: { get: async () => { const s = await this.getState() return s && s.locked ? 1 : 0 }, }, LockTargetState: { get: async () => { const s = await this.getState() return s && s.locked ? 1 : 0 }, set: async (v: any) => { await this.setState({ locked: !!v }) }, }, }, }, ], } } async setState(change: any): Promise<any> { if (!this.client) { return { success: false, reason: 'no client' } } // User management actions: add/remove/list users, unlock with pin if (change && typeof change.action === 'string') { const action = change.action try { if (action === 'addUser' && (change.user || change.userId) && (change.pin || change.code)) { const user = change.user ?? change.userId const p = String(change.pin ?? change.code) const body = { command: 'addUserCode', parameter: `${user}:${p}`, commandType: 'command' } return await this.client.setDeviceState(this.opts.id, body) } if (action === 'removeUser' && (change.user || change.userId)) { const user = change.user ?? change.userId const body = { command: 'removeUserCode', parameter: String(user), commandType: 'command' } return await this.client.setDeviceState(this.opts.id, body) } if (action === 'listUsers') { const body = { command: 'listUsers', parameter: 'default', commandType: 'command' } return await this.client.setDeviceState(this.opts.id, body) } if (action === 'unlockWithPin' && (change.pin || change.code)) { const p = String(change.pin ?? change.code) const body = { command: 'unlockWithPin', parameter: p, commandType: 'command' } return await this.client.setDeviceState(this.opts.id, body) } } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, { command: action, parameter: change.parameter ?? 'default', commandType: 'command' }) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } // Support setting lock PIN/passcode via `pin` or `passcode` (fallback) const pin = change?.pin ?? change?.passcode ?? change?.code if (typeof pin === 'string' || typeof pin === 'number') { const body = { command: 'setLockPin', parameter: String(pin), commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } return super.setState(change) } // Matter DoorLock descriptor including simple user-management actions createMatterAccessory(api: any): any { return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters: [ { // DoorLock cluster type: 'DoorLock', clusterId: MATTER_CLUSTER_IDS.DoorLock, attributes: { lockState: { read: async () => { const s = await this.getState(); return !!(s && s.locked) }, write: async (v: any) => this.setState({ locked: !!v }) }, [MATTER_ATTRIBUTE_IDS.DoorLock.LockState]: { read: async () => { const s = await this.getState(); return !!(s && s.locked) }, write: async (v: any) => this.setState({ locked: !!v }) }, }, }, { // DoorLock user mgmt cluster (conceptual) type: 'DoorLockUserManagement', clusterId: 0x0301, attributes: { addUser: { write: async (v: any) => this.setState({ action: 'addUser', user: v?.user, pin: v?.pin }) }, removeUser: { write: async (v: any) => this.setState({ action: 'removeUser', user: v?.user }) }, listUsers: { read: async () => { const r = await this.setState({ action: 'listUsers' }); return r }, write: undefined }, unlockWithPin: { write: async (v: any) => this.setState({ action: 'unlockWithPin', pin: v?.pin }) }, }, }, ], } } } export class HumidifierDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'HumidifierDehumidifier', characteristics: { Active: { get: async () => { const s = await this.getState() return s && s.on ? 1 : 0 }, set: async (v: any) => { await this.setState({ on: v === 1 }) }, }, CurrentHumidifierDehumidifierState: { get: async () => { const s = await this.getState() return s && s.on ? 2 : 0 // 0 = Inactive, 2 = Humidifying }, }, TargetHumidifierDehumidifierState: { get: async () => 1, // 1 = Humidifier set: async (v: any) => { // Support humidifier mode }, }, CurrentRelativeHumidity: { get: async () => { const s = await this.getState() return typeof s.humidity === 'number' ? s.humidity : 0 }, }, RelativeHumidityHumidifierThreshold: { get: async () => { const s = await this.getState() return typeof s.targetHumidity === 'number' ? s.targetHumidity : 50 }, set: async (v: any) => { await this.setState({ humidity: Number(v) }) }, }, }, }, ], } } async setState(change: any): Promise<any> { if (!this.client) { return { success: false, reason: 'no client' } } if (typeof change.humidity === 'number') { const v = String(Number(change.humidity)) const body = { command: 'setHumidity', parameter: v, commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } if (typeof change.dry === 'boolean') { const body = { command: 'setDry', parameter: change.dry ? 'on' : 'off', commandType: 'command' } try { return await this.client.setDeviceState(this.opts.id, body) } catch (err) { try { if (typeof this.client.sendCommand === 'function') { return await this.client.sendCommand(this.opts.id, body) } } catch (e) {} const e = err as any return { success: false, reason: e?.message ?? String(e) } } } return super.setState(change) } } // Provide Matter descriptor for humidifier (humidity and on/off) export class HumidifierMatterDevice extends HumidifierDevice { async createMatterAccessory(api: any): Promise<any> { return { id: this.opts.id, name: this.opts.name ?? this.opts.type, protocol: 'matter', clusters: [ { // Relative Humidity Sensor cluster type: 'RelativeHumiditySensor', clusterId: MATTER_CLUSTER_IDS.RelativeHumidityMeasurement, attributes: { currentRelativeHumidity: { read: async () => { const s = await this.getState(); return typeof s.humidity === 'number' ? s.humidity : 0 }, write: undefined }, [MATTER_ATTRIBUTE_IDS.RelativeHumidityMeasurement.MeasuredValue]: { read: async () => { const s = await this.getState(); return typeof s.humidity === 'number' ? s.humidity : 0 }, write: undefined }, }, }, { type: 'OnOff', clusterId: MATTER_CLUSTER_IDS.OnOff, attributes: { onOff: { read: async () => { const s = await this.getState(); return !!s?.on }, write: async (v: any) => this.setState({ on: !!v }) }, [MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => { const s = await this.getState(); return !!s?.on }, write: async (v: any) => this.setState({ on: !!v }) }, }, }, ], } } } export class TemperatureSensorDevice extends GenericDevice { createHAPAccessory(api: any) { return { services: [ { type: 'TemperatureSensor', characteristics: { Curre