UNPKG

@switchbot/homebridge-switchbot

Version:

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

1,058 lines (1,057 loc) 62.4 kB
// Utility: Validate BLE response length before parsing /* eslint-disable style/max-statements-per-line, unused-imports/no-unused-vars */ import { Buffer } from 'node:buffer'; import { MATTER_ATTRIBUTE_IDS, MATTER_CLUSTER_IDS } from '../utils.js'; import { DeviceBase } from './deviceBase.js'; function validateBLEResponseLength(buf, expected, context = '', log) { 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(); // Module-scope regex pattern to avoid recompilation const HEX_COLOR_REGEX = /^#?[0-9A-F]{6}$/i; export class GenericDevice extends DeviceBase { log; _blePollTimer = null; _blePollIntervalMs; _blePollingEnabled; constructor(opts, cfg) { 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. */ _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?.message); } }, this._blePollIntervalMs); } /** * Clean up BLE polling timer on destroy. */ async destroy() { 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. */ 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) => { // 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. */ async _awaitBLENotification(timeoutMs = 5000) { 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((resolve, reject) => { let timer; const handler = (payload) => { 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() { // 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) { // 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 = {}; 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; 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; return { success: false, reason: e?.message ?? String(e) }; } } createHAPAccessory(api) { // 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) => { 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) { // Dynamically detect features from getState() const state = await this.getState(); const clusters = []; // 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) => 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) => 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) => this.setState({ brightness: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.LevelControl.CurrentLevel]: { read: async () => (await this.getState()).brightness ?? 100, write: async (v) => 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) => this.setState({ hue: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => (await this.getState()).hue ?? 0, write: async (v) => this.setState({ hue: Number(v) }) }, colorSaturation: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => this.setState({ saturation: Number(v) }) }, [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentSaturation]: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => 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) => 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) => 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) => 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) => 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) { 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) => { await this.setState({ position: Number(v) }); }, }, }, }, ], }; } // Matter-specific descriptor for Curtain (WindowCovering cluster) with new attributes async createMatterAccessory(api) { // Get current state for dynamic attributes const state = await this.getState(); // Compose attributes for Matter WindowCovering cluster const attributes = { 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) => 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) => 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 = [windowCoveringCluster]; const clusters = [...clustersArr]; // Always set clusters.windowCovering to the WindowCovering cluster by clusterId const foundWC = clustersArr.find((c) => 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) { return { services: [ { type: 'Fan', characteristics: { On: { get: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on')); }, set: async (v) => { await this.setState({ on: !!v }); }, }, RotationSpeed: { get: async () => { const s = await this.getState(); return typeof s.speed === 'number' ? s.speed : 0; }, set: async (v) => { await this.setState({ speed: Number(v) }); }, }, }, }, ], }; } async setState(change) { 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; 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 = '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; return { success: false, reason: e?.message ?? String(e) }; } } return super.setState(change); } // Matter-specific descriptor for Fan createMatterAccessory(api) { 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) => 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) => 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) => 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) => this.setState({ speed: Number(v) }) }, oscillation: { read: async () => { const s = await this.getState(); return !!s?.oscillating; }, write: async (v) => this.setState({ oscillate: !!v }) }, swingMode: { read: async () => { const s = await this.getState(); return s?.swingMode ?? null; }, write: async (v) => this.setState({ swingMode: v }) }, }, }, ], }; } } export class LightDevice extends GenericDevice { createHAPAccessory(api) { 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) => { 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) => { 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, s, l) => ({ 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) => { 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) => { 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) => { await this.setState({ colorTemperature: Number(v) }); }, }, }, }, ], }; } async setState(change) { 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; 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; 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; 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) { 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) => 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) => 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) => 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) => 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) => 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) => this.setState({ hue: Number(v) }) }, colorSaturation: { read: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0; }, write: async (v) => 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) => 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) => 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) => this.setState({ colorTemperature: Number(v) }) }, }, }, ], }; } } export class LightStripDevice extends LightDevice { } export class MotionSensorDevice extends GenericDevice { createHAPAccessory(api) { return { services: [ { type: 'MotionSensor', characteristics: { MotionDetected: { get: async () => { const s = await this.getState(); return !!(s && s.motion === true); }, }, }, }, ], }; } async setState(change) { 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; 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 = '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; return { success: false, reason: e?.message ?? String(e) }; } } return super.setState(change); } } export class ContactSensorDevice extends GenericDevice { createHAPAccessory(api) { 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) { return super.createHAPAccessory(api); } } export class LockDevice extends GenericDevice { createHAPAccessory(api) { 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) => { await this.setState({ locked: !!v }); }, }, }, }, ], }; } async setState(change) { 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);