@switchbot/homebridge-switchbot 5.0.0-beta.17 → 5.0.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/config.schema.json +17 -2
- package/dist/platform-matter.d.ts +8 -6
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +404 -41
- package/dist/platform-matter.js.map +1 -1
- package/dist/platform-matter.test.js +83 -0
- package/dist/platform-matter.test.js.map +1 -1
- package/dist/settings.d.ts +1 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +2 -2
- package/src/platform-matter.test.ts +98 -0
- package/src/platform-matter.ts +399 -43
- package/src/settings.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/)
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### What's Changed
|
|
8
|
+
- Matter platform: register only devices discovered via the SwitchBot OpenAPI by default. Per-device config overrides (by deviceId) are correctly merged into discovered devices.
|
|
9
|
+
- Add `options.allowConfigOnlyDevices` (boolean) to opt-in to registering devices that exist only in config (not discovered).
|
|
10
|
+
- Add unit tests for per-device merging and config-only behavior.
|
|
11
|
+
|
|
5
12
|
## [4.3.1](https://github.com/OpenWonderLabs/homebridge-switchbot/releases/tag/v4.3.1) (2025-03-04)
|
|
6
13
|
|
|
7
14
|
# *No New Releases During Lent*
|
package/config.schema.json
CHANGED
|
@@ -13077,6 +13077,11 @@
|
|
|
13077
13077
|
}
|
|
13078
13078
|
]
|
|
13079
13079
|
},
|
|
13080
|
+
"allowConfigOnlyDevices": {
|
|
13081
|
+
"title": "Allow Config-only Devices",
|
|
13082
|
+
"type": "boolean",
|
|
13083
|
+
"description": "If true, devices declared in options.devices that are not discovered by the SwitchBot OpenAPI will still be included (config-only devices). Default: false."
|
|
13084
|
+
},
|
|
13080
13085
|
"allowInvalidCharacters": {
|
|
13081
13086
|
"title": "Allow Invalid Characters",
|
|
13082
13087
|
"type": "boolean",
|
|
@@ -14822,8 +14827,18 @@
|
|
|
14822
14827
|
"key": "options.pushRate",
|
|
14823
14828
|
"description": "<em class='primary-text'>Specifies the interval, in seconds, between pushes to the SwitchBot API.</em>"
|
|
14824
14829
|
},
|
|
14825
|
-
|
|
14826
|
-
|
|
14830
|
+
{
|
|
14831
|
+
"key": "options.logging",
|
|
14832
|
+
"description": "<em class='primary-text'>Specifies the logging level for the plugin. Default: info.</em>"
|
|
14833
|
+
},
|
|
14834
|
+
{
|
|
14835
|
+
"key": "options.allowConfigOnlyDevices",
|
|
14836
|
+
"description": "<em class='primary-text'>If true, devices declared in options.devices that are not discovered by the SwitchBot OpenAPI will still be included (config-only devices). Default: false.</em>"
|
|
14837
|
+
},
|
|
14838
|
+
{
|
|
14839
|
+
"key": "options.allowInvalidCharacters",
|
|
14840
|
+
"description": "<em class='primary-text'>Allows device names with characters that are normally invalid in HomeKit. Use with caution, as this may lead to unexpected behavior.</em>"
|
|
14841
|
+
}
|
|
14827
14842
|
]
|
|
14828
14843
|
}
|
|
14829
14844
|
]
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import type { API, DynamicPlatformPlugin, Logging, SerializedMatterAccessory } from 'homebridge';
|
|
2
2
|
import type { bodyChange, device } from 'node-switchbot';
|
|
3
3
|
import type { SwitchBotPlatformConfig } from './settings.js';
|
|
4
|
-
/**
|
|
5
|
-
* MatterPlatform
|
|
6
|
-
* Demonstrates all available Matter device types in Homebridge
|
|
7
|
-
*
|
|
8
|
-
* Organized by official Matter Specification v1.4.1 categories
|
|
9
|
-
*/
|
|
10
4
|
export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
11
5
|
readonly log: Logging;
|
|
12
6
|
readonly config: SwitchBotPlatformConfig;
|
|
@@ -15,6 +9,8 @@ export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
15
9
|
private switchBotAPI?;
|
|
16
10
|
private switchBotBLE?;
|
|
17
11
|
private discoveredDevices;
|
|
12
|
+
private accessoryInstances;
|
|
13
|
+
private refreshTimers;
|
|
18
14
|
private bleEventHandler;
|
|
19
15
|
private platformLogging?;
|
|
20
16
|
infoLog: (...args: any[]) => void;
|
|
@@ -68,6 +64,12 @@ export declare class SwitchBotMatterPlatform implements DynamicPlatformPlugin {
|
|
|
68
64
|
* Returns null when serviceData is falsy or parsing fails.
|
|
69
65
|
*/
|
|
70
66
|
private parseAdvertisementForDevice;
|
|
67
|
+
/**
|
|
68
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
69
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
70
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
71
|
+
*/
|
|
72
|
+
private applyStatusToAccessory;
|
|
71
73
|
/**
|
|
72
74
|
* Required for DynamicPlatformPlugin
|
|
73
75
|
* Called when homebridge restores cached accessories from disk at startup
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform-matter.d.ts","sourceRoot":"","sources":["../src/platform-matter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,GAAG,EACH,qBAAqB,EACrB,OAAO,EAEP,yBAAyB,EAC1B,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAExD,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"platform-matter.d.ts","sourceRoot":"","sources":["../src/platform-matter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,GAAG,EACH,qBAAqB,EACrB,OAAO,EAEP,yBAAyB,EAC1B,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAExD,OAAO,KAAK,EAAiB,uBAAuB,EAAE,MAAM,eAAe,CAAA;AA8B3E,qBAAa,uBAAwB,YAAW,qBAAqB;aAkCjD,GAAG,EAAE,OAAO;aACZ,MAAM,EAAE,uBAAuB;aAC/B,GAAG,EAAE,GAAG;IA9B1B,SAAgB,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAY;IAErF,OAAO,CAAC,YAAY,CAAC,CAAkB;IACvC,OAAO,CAAC,YAAY,CAAC,CAAc;IAEnC,OAAO,CAAC,iBAAiB,CAAe;IAExC,OAAO,CAAC,kBAAkB,CAA8B;IAExD,OAAO,CAAC,aAAa,CAAyC;IAE9D,OAAO,CAAC,eAAe,CAA8C;IAErE,OAAO,CAAC,eAAe,CAAC,CAAS;IAGjC,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,UAAU,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACrC,eAAe,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC1C,OAAO,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAClC,YAAY,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACvC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,aAAa,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACxC,QAAQ,EAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IACnC,cAAc,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IACvC,uBAAuB,EAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;gBAG9B,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,uBAAuB,EAC/B,GAAG,EAAE,GAAG;IAwH1B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAIzB;;;OAGG;IACH,OAAO,CAAC,eAAe;IAgBvB;;;OAGG;YACW,sBAAsB;IAuCpC;;;OAGG;IACH,OAAO,CAAC,oBAAoB;IAW5B;;;OAGG;YACW,yBAAyB;IA2gBvC;;OAEG;YACW,eAAe;IAwB7B;;OAEG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,SAAI,EAAE,mBAAmB,SAAO,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,GAAG,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;IA0BzJ;;;OAGG;IACH,OAAO,CAAC,2BAA2B;IAwDnC;;;;OAIG;YACW,sBAAsB;IA6LpC;;;OAGG;IACH,kBAAkB;IAMlB;;;;;OAKG;IACH,wBAAwB,CAAC,SAAS,EAAE,yBAAyB;IAK7D;;OAEG;YACW,yBAAyB;IAwFvC;;OAEG;YACW,yBAAyB;IAqCvC;;OAEG;YACW,wBAAwB;IA8CtC;;OAEG;YACW,0BAA0B;IAsBxC;;OAEG;YACW,wBAAwB;IAsBtC;;OAEG;YACW,uBAAuB;IA0DrC;;OAEG;YACW,uBAAuB;IAkCrC;;OAEG;YACW,oBAAoB;IA4BlC;;;;;OAKG;YACW,wBAAwB;IAsBtC;;;;;;OAMG;YACW,qBAAqB;CAqBpC"}
|
package/dist/platform-matter.js
CHANGED
|
@@ -2,12 +2,6 @@ import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot';
|
|
|
2
2
|
import { ColorLightAccessory, ColorTemperatureLightAccessory, ContactSensorAccessory, DimmableLightAccessory, DoorLockAccessory, ExtendedColorLightAccessory, FanAccessory, HumiditySensorAccessory, LeakSensorAccessory, LightSensorAccessory, OccupancySensorAccessory, OnOffLightAccessory, OnOffOutletAccessory, OnOffSwitchAccessory, PowerStripAccessory, RoboticVacuumAccessory, SmokeCOAlarmAccessory, TemperatureSensorAccessory, ThermostatAccessory, VenetianBlindAccessory, WindowBlindAccessory, } from './devices-matter/index.js';
|
|
3
3
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
4
4
|
import { cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js';
|
|
5
|
-
/**
|
|
6
|
-
* MatterPlatform
|
|
7
|
-
* Demonstrates all available Matter device types in Homebridge
|
|
8
|
-
*
|
|
9
|
-
* Organized by official Matter Specification v1.4.1 categories
|
|
10
|
-
*/
|
|
11
5
|
export class SwitchBotMatterPlatform {
|
|
12
6
|
log;
|
|
13
7
|
config;
|
|
@@ -22,6 +16,10 @@ export class SwitchBotMatterPlatform {
|
|
|
22
16
|
switchBotBLE;
|
|
23
17
|
// discovered devices cache
|
|
24
18
|
discoveredDevices = [];
|
|
19
|
+
// Registry of created accessory instances keyed by normalized deviceId
|
|
20
|
+
accessoryInstances = new Map();
|
|
21
|
+
// Refresh timers keyed by normalized deviceId
|
|
22
|
+
refreshTimers = new Map();
|
|
25
23
|
// BLE event handlers keyed by device MAC (formatted)
|
|
26
24
|
bleEventHandler = {};
|
|
27
25
|
// Platform logging toggle (can be controlled via UI or config)
|
|
@@ -170,10 +168,20 @@ export class SwitchBotMatterPlatform {
|
|
|
170
168
|
* find matching item in a2 (discovered devices) and merge them with user overrides last.
|
|
171
169
|
*/
|
|
172
170
|
mergeByDeviceId(a1, a2) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
171
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
172
|
+
const result = [];
|
|
173
|
+
for (const itm of (a1 || [])) {
|
|
174
|
+
const matchingItem = (a2 || []).find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId));
|
|
175
|
+
if (matchingItem) {
|
|
176
|
+
result.push(Object.assign({}, matchingItem, itm));
|
|
177
|
+
}
|
|
178
|
+
else if (allowConfigOnly) {
|
|
179
|
+
// include config-only device as-is when explicitly allowed
|
|
180
|
+
result.push(Object.assign({}, itm));
|
|
181
|
+
}
|
|
182
|
+
// otherwise skip config-only entries
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
177
185
|
}
|
|
178
186
|
/**
|
|
179
187
|
* Merge discovered devices with deviceConfig (per deviceType) and per-device overrides
|
|
@@ -198,7 +206,8 @@ export class SwitchBotMatterPlatform {
|
|
|
198
206
|
// For any entries in merged (which are based on config.options.devices), ensure final per-device merges include deviceId-specific config
|
|
199
207
|
const final = [];
|
|
200
208
|
for (const device of merged) {
|
|
201
|
-
|
|
209
|
+
// Find per-device config entry by deviceId (config.options.devices is an array)
|
|
210
|
+
const deviceIdConfig = (this.config.options?.devices || []).find((d) => this.normalizeDeviceId(d.deviceId) === this.normalizeDeviceId(device.deviceId)) || {};
|
|
202
211
|
const deviceWithConfig = Object.assign({}, device, deviceIdConfig);
|
|
203
212
|
final.push(deviceWithConfig);
|
|
204
213
|
}
|
|
@@ -234,7 +243,7 @@ export class SwitchBotMatterPlatform {
|
|
|
234
243
|
const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device';
|
|
235
244
|
const serial = dev.deviceId ?? 'unknown';
|
|
236
245
|
const manufacturer = 'SwitchBot';
|
|
237
|
-
const model = dev.deviceType ?? 'SwitchBot';
|
|
246
|
+
const model = dev.model ?? dev.deviceType ?? 'SwitchBot';
|
|
238
247
|
const firmware = dev.firmware ?? dev.version ?? '0.0.0';
|
|
239
248
|
// Helper to build a default opts object consumed by the matter device classes
|
|
240
249
|
const baseOpts = {
|
|
@@ -492,6 +501,15 @@ export class SwitchBotMatterPlatform {
|
|
|
492
501
|
const opts = Object.assign({}, baseOpts, { handlers });
|
|
493
502
|
// Instantiate the device class and return its serialized accessory
|
|
494
503
|
const instance = new Ctor(this.api, this.log, opts);
|
|
504
|
+
// Save instance in registry so platform can call device-specific update methods if needed
|
|
505
|
+
try {
|
|
506
|
+
if (dev?.deviceId) {
|
|
507
|
+
this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
this.debugLog('Failed to register accessory instance: %s', e?.message ?? e);
|
|
512
|
+
}
|
|
495
513
|
try {
|
|
496
514
|
this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
497
515
|
}
|
|
@@ -523,6 +541,134 @@ export class SwitchBotMatterPlatform {
|
|
|
523
541
|
const [h, s] = rgb2hs(r, g, b);
|
|
524
542
|
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
|
|
525
543
|
}
|
|
544
|
+
// Battery -> powerSource cluster (common mapping)
|
|
545
|
+
if (parsed.battery !== undefined) {
|
|
546
|
+
try {
|
|
547
|
+
const percentage = Number(parsed.battery);
|
|
548
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
549
|
+
let batChargeLevel = 0;
|
|
550
|
+
if (percentage < 20) {
|
|
551
|
+
batChargeLevel = 2;
|
|
552
|
+
}
|
|
553
|
+
else if (percentage < 40) {
|
|
554
|
+
batChargeLevel = 1;
|
|
555
|
+
}
|
|
556
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel });
|
|
557
|
+
}
|
|
558
|
+
catch (e) {
|
|
559
|
+
this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Temperature -> temperatureMeasurement
|
|
563
|
+
if (parsed.temperature !== undefined) {
|
|
564
|
+
try {
|
|
565
|
+
const c = Number(parsed.temperature);
|
|
566
|
+
const measured = Math.round(c * 100);
|
|
567
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured });
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Humidity -> relativeHumidityMeasurement
|
|
574
|
+
if (parsed.humidity !== undefined) {
|
|
575
|
+
try {
|
|
576
|
+
const percent = Number(parsed.humidity);
|
|
577
|
+
const measured = Math.round(percent * 100);
|
|
578
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured });
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Contact / Leak -> BooleanState
|
|
585
|
+
if (parsed.contact !== undefined || parsed.leak !== undefined) {
|
|
586
|
+
try {
|
|
587
|
+
// Some devices report contact as true=open; ContactSensor expects inverted value
|
|
588
|
+
const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact);
|
|
589
|
+
const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak);
|
|
590
|
+
if (isContactOpen !== undefined) {
|
|
591
|
+
// If this is a contact sensor device type, invert; otherwise set conservatively
|
|
592
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
593
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen });
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (leakDetected !== undefined) {
|
|
600
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
catch (e) {
|
|
604
|
+
this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Motion -> occupancy
|
|
608
|
+
if (parsed.motion !== undefined) {
|
|
609
|
+
try {
|
|
610
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } });
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// Lock state -> doorLock
|
|
617
|
+
if (parsed.lock !== undefined) {
|
|
618
|
+
try {
|
|
619
|
+
const s = String(parsed.lock).toLowerCase();
|
|
620
|
+
let lockState = 0;
|
|
621
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
622
|
+
lockState = 1;
|
|
623
|
+
}
|
|
624
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
625
|
+
lockState = 2;
|
|
626
|
+
}
|
|
627
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState });
|
|
628
|
+
}
|
|
629
|
+
catch (e) {
|
|
630
|
+
this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// Position / Cover -> WindowCovering (convert open percent to closed*100)
|
|
634
|
+
if (parsed.position !== undefined) {
|
|
635
|
+
try {
|
|
636
|
+
const openPercent = Number(parsed.position);
|
|
637
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
638
|
+
const value = Math.round(closedPercent * 100);
|
|
639
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value });
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Fan speed -> FanControl
|
|
646
|
+
if (parsed.fanSpeed !== undefined) {
|
|
647
|
+
try {
|
|
648
|
+
const percent = Number(parsed.fanSpeed);
|
|
649
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent });
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
|
|
656
|
+
if (parsed.rvcRunMode !== undefined) {
|
|
657
|
+
try {
|
|
658
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) });
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (parsed.rvcOperationalState !== undefined) {
|
|
665
|
+
try {
|
|
666
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) });
|
|
667
|
+
}
|
|
668
|
+
catch (e) {
|
|
669
|
+
this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
526
672
|
// If we parsed something from serviceData prefer it and return early
|
|
527
673
|
if (serviceData) {
|
|
528
674
|
return;
|
|
@@ -544,35 +690,8 @@ export class SwitchBotMatterPlatform {
|
|
|
544
690
|
const respAny = response;
|
|
545
691
|
const body = respAny?.body ?? respAny;
|
|
546
692
|
const status = body?.status ?? body;
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1);
|
|
550
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: on });
|
|
551
|
-
}
|
|
552
|
-
// Brightness
|
|
553
|
-
if (status?.brightness !== undefined) {
|
|
554
|
-
const level = Math.round((Number(status.brightness) / 100) * 254);
|
|
555
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
556
|
-
}
|
|
557
|
-
// Color
|
|
558
|
-
if (status?.color !== undefined) {
|
|
559
|
-
const color = String(status.color);
|
|
560
|
-
let r = 0;
|
|
561
|
-
let g = 0;
|
|
562
|
-
let b = 0;
|
|
563
|
-
if (color.includes(':')) {
|
|
564
|
-
const parts = color.split(':').map(Number);
|
|
565
|
-
[r, g, b] = parts;
|
|
566
|
-
}
|
|
567
|
-
else if (color.startsWith('#')) {
|
|
568
|
-
const hex = color.replace('#', '');
|
|
569
|
-
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
570
|
-
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
571
|
-
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
572
|
-
}
|
|
573
|
-
const [h, s] = rgb2hs(r, g, b);
|
|
574
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
|
|
575
|
-
}
|
|
693
|
+
// Use centralized mapper which prefers accessory instance update helpers
|
|
694
|
+
await this.applyStatusToAccessory(uuidLocal, dev, status);
|
|
576
695
|
}
|
|
577
696
|
catch (e) {
|
|
578
697
|
this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
@@ -582,6 +701,53 @@ export class SwitchBotMatterPlatform {
|
|
|
582
701
|
catch (e) {
|
|
583
702
|
this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
584
703
|
}
|
|
704
|
+
// Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
|
|
705
|
+
try {
|
|
706
|
+
const nid = this.normalizeDeviceId(dev.deviceId);
|
|
707
|
+
// Clear any existing timer for this device
|
|
708
|
+
const existing = this.refreshTimers.get(nid);
|
|
709
|
+
if (existing) {
|
|
710
|
+
clearInterval(existing);
|
|
711
|
+
this.refreshTimers.delete(nid);
|
|
712
|
+
}
|
|
713
|
+
const refreshRateSec = dev.refreshRate ?? this.config.options?.refreshRate ?? 300;
|
|
714
|
+
if (this.switchBotAPI && refreshRateSec && Number(refreshRateSec) > 0) {
|
|
715
|
+
// Immediate one-shot to populate initial state
|
|
716
|
+
;
|
|
717
|
+
(async () => {
|
|
718
|
+
try {
|
|
719
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
720
|
+
if (statusCode === 100 || statusCode === 200) {
|
|
721
|
+
const respAny = response;
|
|
722
|
+
const body = respAny?.body ?? respAny;
|
|
723
|
+
const status = body?.status ?? body;
|
|
724
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
this.debugLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
729
|
+
}
|
|
730
|
+
})();
|
|
731
|
+
const timer = setInterval(async () => {
|
|
732
|
+
try {
|
|
733
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
734
|
+
if (statusCode === 100 || statusCode === 200) {
|
|
735
|
+
const respAny = response;
|
|
736
|
+
const body = respAny?.body ?? respAny;
|
|
737
|
+
const status = body?.status ?? body;
|
|
738
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch (e) {
|
|
742
|
+
this.debugLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
743
|
+
}
|
|
744
|
+
}, Number(refreshRateSec) * 1000);
|
|
745
|
+
this.refreshTimers.set(nid, timer);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch (e) {
|
|
749
|
+
this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
750
|
+
}
|
|
585
751
|
return instance.toAccessory();
|
|
586
752
|
}
|
|
587
753
|
/**
|
|
@@ -689,6 +855,203 @@ export class SwitchBotMatterPlatform {
|
|
|
689
855
|
return null;
|
|
690
856
|
}
|
|
691
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
860
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
861
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
862
|
+
*/
|
|
863
|
+
async applyStatusToAccessory(uuidLocal, dev, status) {
|
|
864
|
+
if (!status) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined;
|
|
868
|
+
// Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
|
|
869
|
+
const safeUpdate = async (cluster, attributes, methodName) => {
|
|
870
|
+
try {
|
|
871
|
+
if (instance && methodName && typeof instance[methodName] === 'function') {
|
|
872
|
+
// prefer device-specific update helpers when available
|
|
873
|
+
await instance[methodName](...(Object.values(attributes)));
|
|
874
|
+
}
|
|
875
|
+
else if (instance && typeof instance.updateState === 'function') {
|
|
876
|
+
// some accessories expose updateState that accepts cluster and attributes
|
|
877
|
+
await instance.updateState(cluster, attributes);
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
catch (e) {
|
|
884
|
+
this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
try {
|
|
888
|
+
// On/Off
|
|
889
|
+
if (status?.power !== undefined) {
|
|
890
|
+
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true);
|
|
891
|
+
await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState');
|
|
892
|
+
}
|
|
893
|
+
// Brightness
|
|
894
|
+
if (status?.brightness !== undefined) {
|
|
895
|
+
const level = Math.round((Number(status.brightness) / 100) * 254);
|
|
896
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: level }, 'updateBrightness');
|
|
897
|
+
}
|
|
898
|
+
// Color
|
|
899
|
+
if (status?.color !== undefined) {
|
|
900
|
+
const color = String(status.color);
|
|
901
|
+
let r = 0;
|
|
902
|
+
let g = 0;
|
|
903
|
+
let b = 0;
|
|
904
|
+
if (color.includes(':')) {
|
|
905
|
+
const parts = color.split(':').map(Number);
|
|
906
|
+
[r, g, b] = parts;
|
|
907
|
+
}
|
|
908
|
+
else if (color.startsWith('#')) {
|
|
909
|
+
const hex = color.replace('#', '');
|
|
910
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
911
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
912
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
913
|
+
}
|
|
914
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
915
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) }, 'updateHueSaturation');
|
|
916
|
+
}
|
|
917
|
+
// Battery/powerSource
|
|
918
|
+
if (status?.battery !== undefined || status?.batt !== undefined) {
|
|
919
|
+
try {
|
|
920
|
+
const percentage = Number(status?.battery ?? status?.batt);
|
|
921
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
922
|
+
let batChargeLevel = 0;
|
|
923
|
+
if (percentage < 20) {
|
|
924
|
+
batChargeLevel = 2;
|
|
925
|
+
}
|
|
926
|
+
else if (percentage < 40) {
|
|
927
|
+
batChargeLevel = 1;
|
|
928
|
+
}
|
|
929
|
+
await safeUpdate('powerSource', { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage');
|
|
930
|
+
}
|
|
931
|
+
catch (e) {
|
|
932
|
+
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
// Temperature + thermostat
|
|
936
|
+
if (status?.temperature !== undefined || status?.temp !== undefined) {
|
|
937
|
+
try {
|
|
938
|
+
const c = Number(status?.temperature ?? status?.temp);
|
|
939
|
+
const measured = Math.round(c * 100);
|
|
940
|
+
await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature');
|
|
941
|
+
// Thermostat-specific mapping
|
|
942
|
+
if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
|
|
943
|
+
const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint);
|
|
944
|
+
const val = Math.round(target * 100);
|
|
945
|
+
await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
catch (e) {
|
|
949
|
+
this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// Humidity
|
|
953
|
+
if (status?.humidity !== undefined || status?.h !== undefined) {
|
|
954
|
+
try {
|
|
955
|
+
const percent = Number(status?.humidity ?? status?.h);
|
|
956
|
+
const measured = Math.round(percent * 100);
|
|
957
|
+
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity');
|
|
958
|
+
}
|
|
959
|
+
catch (e) {
|
|
960
|
+
this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
// Contact / Leak -> BooleanState
|
|
964
|
+
if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
|
|
965
|
+
try {
|
|
966
|
+
const isContactOpen = status?.contact ?? status?.open;
|
|
967
|
+
if (isContactOpen !== undefined) {
|
|
968
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
969
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const leakDetected = status?.leak ?? status?.water;
|
|
976
|
+
if (leakDetected !== undefined) {
|
|
977
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState');
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// Motion -> occupancy
|
|
985
|
+
if (status?.motion !== undefined || status?.m !== undefined) {
|
|
986
|
+
try {
|
|
987
|
+
const detected = Boolean(status?.motion ?? status?.m);
|
|
988
|
+
await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy');
|
|
989
|
+
}
|
|
990
|
+
catch (e) {
|
|
991
|
+
this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// Lock state
|
|
995
|
+
if (status?.lock !== undefined) {
|
|
996
|
+
try {
|
|
997
|
+
const s = String(status.lock).toLowerCase();
|
|
998
|
+
let lockState = 0;
|
|
999
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
1000
|
+
lockState = 1;
|
|
1001
|
+
}
|
|
1002
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
1003
|
+
lockState = 2;
|
|
1004
|
+
}
|
|
1005
|
+
await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState');
|
|
1006
|
+
}
|
|
1007
|
+
catch (e) {
|
|
1008
|
+
this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
// Cover position
|
|
1012
|
+
if (status?.position !== undefined || status?.percent !== undefined) {
|
|
1013
|
+
try {
|
|
1014
|
+
const openPercent = Number(status?.position ?? status?.percent);
|
|
1015
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
1016
|
+
const value = Math.round(closedPercent * 100);
|
|
1017
|
+
await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition');
|
|
1018
|
+
}
|
|
1019
|
+
catch (e) {
|
|
1020
|
+
this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// Fan
|
|
1024
|
+
if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
|
|
1025
|
+
try {
|
|
1026
|
+
const percent = Number(status?.fanSpeed ?? status?.speed);
|
|
1027
|
+
await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed');
|
|
1028
|
+
}
|
|
1029
|
+
catch (e) {
|
|
1030
|
+
this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// Robot vacuum: run/operational/clean modes
|
|
1034
|
+
if (status?.rvcRunMode !== undefined) {
|
|
1035
|
+
try {
|
|
1036
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode');
|
|
1037
|
+
}
|
|
1038
|
+
catch (e) {
|
|
1039
|
+
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (status?.rvcOperationalState !== undefined) {
|
|
1043
|
+
try {
|
|
1044
|
+
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState');
|
|
1045
|
+
}
|
|
1046
|
+
catch (e) {
|
|
1047
|
+
this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
catch (e) {
|
|
1052
|
+
this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
692
1055
|
/**
|
|
693
1056
|
* Required for DynamicPlatformPlugin
|
|
694
1057
|
* Called when homebridge restores cached accessories from disk at startup
|