@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
text/typescript
// 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