@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
JavaScript
// 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);