@switchbot/homebridge-switchbot 5.0.0-beta.7 → 5.0.0-beta.70
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 +15 -0
- package/README.md +45 -3
- package/config.schema.json +871 -13754
- package/dist/devices-hap/airpurifier.d.ts.map +1 -1
- package/dist/devices-hap/airpurifier.js +12 -6
- package/dist/devices-hap/airpurifier.js.map +1 -1
- package/dist/devices-hap/blindtilt.js +3 -3
- package/dist/devices-hap/bot.d.ts.map +1 -1
- package/dist/devices-hap/bot.js +16 -5
- package/dist/devices-hap/bot.js.map +1 -1
- package/dist/devices-hap/ceilinglight.d.ts.map +1 -1
- package/dist/devices-hap/ceilinglight.js +13 -7
- package/dist/devices-hap/ceilinglight.js.map +1 -1
- package/dist/devices-hap/colorbulb.d.ts.map +1 -1
- package/dist/devices-hap/colorbulb.js +49 -9
- package/dist/devices-hap/colorbulb.js.map +1 -1
- package/dist/devices-hap/contact.js +3 -3
- package/dist/devices-hap/curtain.js +2 -2
- package/dist/devices-hap/curtain.js.map +1 -1
- package/dist/devices-hap/device.d.ts +18 -8
- package/dist/devices-hap/device.d.ts.map +1 -1
- package/dist/devices-hap/device.js +141 -69
- package/dist/devices-hap/device.js.map +1 -1
- package/dist/devices-hap/fan.d.ts.map +1 -1
- package/dist/devices-hap/fan.js +12 -6
- package/dist/devices-hap/fan.js.map +1 -1
- package/dist/devices-hap/hub.d.ts.map +1 -1
- package/dist/devices-hap/hub.js +6 -5
- package/dist/devices-hap/hub.js.map +1 -1
- package/dist/devices-hap/humidifier.d.ts +5 -0
- package/dist/devices-hap/humidifier.d.ts.map +1 -1
- package/dist/devices-hap/humidifier.js +92 -4
- package/dist/devices-hap/humidifier.js.map +1 -1
- package/dist/devices-hap/iosensor.d.ts.map +1 -1
- package/dist/devices-hap/iosensor.js +36 -21
- package/dist/devices-hap/iosensor.js.map +1 -1
- package/dist/devices-hap/lightstrip.d.ts.map +1 -1
- package/dist/devices-hap/lightstrip.js +38 -8
- package/dist/devices-hap/lightstrip.js.map +1 -1
- package/dist/devices-hap/lock.d.ts.map +1 -1
- package/dist/devices-hap/lock.js +14 -6
- package/dist/devices-hap/lock.js.map +1 -1
- package/dist/devices-hap/meter.d.ts.map +1 -1
- package/dist/devices-hap/meter.js +6 -5
- package/dist/devices-hap/meter.js.map +1 -1
- package/dist/devices-hap/meterplus.d.ts.map +1 -1
- package/dist/devices-hap/meterplus.js +6 -5
- package/dist/devices-hap/meterplus.js.map +1 -1
- package/dist/devices-hap/meterpro.d.ts.map +1 -1
- package/dist/devices-hap/meterpro.js +7 -6
- package/dist/devices-hap/meterpro.js.map +1 -1
- package/dist/devices-hap/motion.js +3 -3
- package/dist/devices-hap/plug.d.ts.map +1 -1
- package/dist/devices-hap/plug.js +11 -6
- package/dist/devices-hap/plug.js.map +1 -1
- package/dist/devices-hap/relayswitch.js +3 -3
- package/dist/devices-hap/robotvacuumcleaner.d.ts.map +1 -1
- package/dist/devices-hap/robotvacuumcleaner.js +13 -6
- package/dist/devices-hap/robotvacuumcleaner.js.map +1 -1
- package/dist/devices-hap/waterdetector.js +3 -3
- package/dist/devices-matter/BaseMatterAccessory.d.ts +27 -0
- package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
- package/dist/devices-matter/BaseMatterAccessory.js +169 -5
- package/dist/devices-matter/BaseMatterAccessory.js.map +1 -1
- package/dist/devices-matter/ColorLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ColorLightAccessory.js +12 -12
- package/dist/devices-matter/ColorLightAccessory.js.map +1 -1
- package/dist/devices-matter/ColorTemperatureLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ColorTemperatureLightAccessory.js +5 -7
- package/dist/devices-matter/ColorTemperatureLightAccessory.js.map +1 -1
- package/dist/devices-matter/DimmableLightAccessory.js +9 -9
- package/dist/devices-matter/DimmableLightAccessory.js.map +1 -1
- package/dist/devices-matter/ExtendedColorLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/ExtendedColorLightAccessory.js +14 -15
- package/dist/devices-matter/ExtendedColorLightAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffLightAccessory.d.ts.map +1 -1
- package/dist/devices-matter/OnOffLightAccessory.js +8 -16
- package/dist/devices-matter/OnOffLightAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffOutletAccessory.d.ts +2 -0
- package/dist/devices-matter/OnOffOutletAccessory.d.ts.map +1 -1
- package/dist/devices-matter/OnOffOutletAccessory.js +10 -7
- package/dist/devices-matter/OnOffOutletAccessory.js.map +1 -1
- package/dist/devices-matter/OnOffSwitchAccessory.js +2 -2
- package/dist/devices-matter/OnOffSwitchAccessory.js.map +1 -1
- package/dist/devices-matter/RoboticVacuumAccessory.d.ts +36 -43
- package/dist/devices-matter/RoboticVacuumAccessory.d.ts.map +1 -1
- package/dist/devices-matter/RoboticVacuumAccessory.js +478 -268
- package/dist/devices-matter/RoboticVacuumAccessory.js.map +1 -1
- package/dist/devices-matter/VenetianBlindAccessory.d.ts +6 -6
- package/dist/devices-matter/VenetianBlindAccessory.d.ts.map +1 -1
- package/dist/devices-matter/VenetianBlindAccessory.js.map +1 -1
- package/dist/devices-matter/WindowBlindAccessory.d.ts +5 -5
- package/dist/devices-matter/WindowBlindAccessory.d.ts.map +1 -1
- package/dist/devices-matter/WindowBlindAccessory.js +57 -6
- package/dist/devices-matter/WindowBlindAccessory.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +219 -19
- package/dist/homebridge-ui/server.js +0 -31
- package/dist/homebridge-ui/server.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -9
- package/dist/index.js.map +1 -1
- package/dist/irdevice/irdevice.d.ts +11 -10
- package/dist/irdevice/irdevice.d.ts.map +1 -1
- package/dist/irdevice/irdevice.js +76 -35
- package/dist/irdevice/irdevice.js.map +1 -1
- package/dist/platform-hap.d.ts +26 -15
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +333 -153
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +93 -6
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +1878 -229
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +75 -7
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/test/apiRequestTracker.test.d.ts +2 -0
- package/dist/test/apiRequestTracker.test.d.ts.map +1 -0
- package/dist/test/apiRequestTracker.test.js +392 -0
- package/dist/test/apiRequestTracker.test.js.map +1 -0
- package/dist/test/hap/device-webhook-context.test.d.ts +2 -0
- package/dist/test/hap/device-webhook-context.test.d.ts.map +1 -0
- package/dist/test/hap/device-webhook-context.test.js +128 -0
- package/dist/test/hap/device-webhook-context.test.js.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.logging.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.logging.test.js +33 -0
- package/dist/test/hap/platform-hap.logging.test.js.map +1 -0
- package/dist/test/hap/platform-hap.test.d.ts +2 -0
- package/dist/test/hap/platform-hap.test.d.ts.map +1 -0
- package/dist/test/hap/platform-hap.test.js +62 -0
- package/dist/test/hap/platform-hap.test.js.map +1 -0
- package/dist/test/helpers/platform-fixtures.d.ts +9 -0
- package/dist/test/helpers/platform-fixtures.d.ts.map +1 -0
- package/dist/test/helpers/platform-fixtures.js +30 -0
- package/dist/test/helpers/platform-fixtures.js.map +1 -0
- package/dist/test/homebridge-ui/server.test.d.ts +2 -0
- package/dist/test/homebridge-ui/server.test.d.ts.map +1 -0
- package/dist/test/homebridge-ui/server.test.js +445 -0
- package/dist/test/homebridge-ui/server.test.js.map +1 -0
- package/dist/{index.test.d.ts.map → test/index.test.d.ts.map} +1 -1
- package/dist/test/index.test.js +19 -0
- package/dist/test/index.test.js.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts +2 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.d.ts.map +1 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js +71 -0
- package/dist/test/matter/devices-matter/baseMatterAccessory.test.js.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.additional.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.additional.test.js +35 -0
- package/dist/test/matter/platform-matter.additional.test.js.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.bleparse.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.bleparse.test.js +43 -0
- package/dist/test/matter/platform-matter.bleparse.test.js.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.cleanup.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.cleanup.test.js +70 -0
- package/dist/test/matter/platform-matter.cleanup.test.js.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.keepstale.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.keepstale.test.js +27 -0
- package/dist/test/matter/platform-matter.keepstale.test.js.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.logging.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.logging.test.js +29 -0
- package/dist/test/matter/platform-matter.logging.test.js.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.mapping.test.js +43 -0
- package/dist/test/matter/platform-matter.mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js +84 -0
- package/dist/test/matter/platform-matter.openapi-mapping.test.js.map +1 -0
- package/dist/test/matter/platform-matter.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.test.js +117 -0
- package/dist/test/matter/platform-matter.test.js.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.unregister.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.unregister.test.js +30 -0
- package/dist/test/matter/platform-matter.unregister.test.js.map +1 -0
- package/dist/test/matter/platform-matter.webhook.test.d.ts +2 -0
- package/dist/test/matter/platform-matter.webhook.test.d.ts.map +1 -0
- package/dist/test/matter/platform-matter.webhook.test.js +46 -0
- package/dist/test/matter/platform-matter.webhook.test.js.map +1 -0
- package/dist/test/utils.test.d.ts +2 -0
- package/dist/test/utils.test.d.ts.map +1 -0
- package/dist/test/utils.test.js +95 -0
- package/dist/test/utils.test.js.map +1 -0
- package/dist/test/verifyconfig.test.d.ts.map +1 -0
- package/dist/{verifyconfig.test.js → test/verifyconfig.test.js} +2 -2
- package/dist/test/verifyconfig.test.js.map +1 -0
- package/dist/utils.d.ts +204 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +713 -33
- package/dist/utils.js.map +1 -1
- package/docs/assets/highlight.css +14 -0
- package/docs/assets/main.js +2 -2
- package/docs/index.html +31 -2
- package/docs/variables/default.html +1 -1
- package/package.json +15 -15
- package/src/devices-hap/airpurifier.ts +11 -6
- package/src/devices-hap/blindtilt.ts +3 -3
- package/src/devices-hap/bot.ts +15 -5
- package/src/devices-hap/ceilinglight.ts +12 -7
- package/src/devices-hap/colorbulb.ts +46 -10
- package/src/devices-hap/contact.ts +3 -3
- package/src/devices-hap/curtain.ts +2 -2
- package/src/devices-hap/device.ts +149 -70
- package/src/devices-hap/fan.ts +11 -6
- package/src/devices-hap/hub.ts +6 -5
- package/src/devices-hap/humidifier.ts +97 -4
- package/src/devices-hap/iosensor.ts +36 -21
- package/src/devices-hap/lightstrip.ts +35 -8
- package/src/devices-hap/lock.ts +13 -6
- package/src/devices-hap/meter.ts +6 -5
- package/src/devices-hap/meterplus.ts +6 -5
- package/src/devices-hap/meterpro.ts +7 -6
- package/src/devices-hap/motion.ts +3 -3
- package/src/devices-hap/plug.ts +10 -6
- package/src/devices-hap/relayswitch.ts +3 -3
- package/src/devices-hap/robotvacuumcleaner.ts +12 -6
- package/src/devices-hap/waterdetector.ts +3 -3
- package/src/devices-matter/BaseMatterAccessory.ts +176 -5
- package/src/devices-matter/ColorLightAccessory.ts +12 -12
- package/src/devices-matter/ColorTemperatureLightAccessory.ts +5 -7
- package/src/devices-matter/DimmableLightAccessory.ts +9 -9
- package/src/devices-matter/ExtendedColorLightAccessory.ts +14 -15
- package/src/devices-matter/OnOffLightAccessory.ts +8 -16
- package/src/devices-matter/OnOffOutletAccessory.ts +12 -7
- package/src/devices-matter/OnOffSwitchAccessory.ts +2 -2
- package/src/devices-matter/RoboticVacuumAccessory.ts +529 -315
- package/src/devices-matter/VenetianBlindAccessory.ts +5 -5
- package/src/devices-matter/WindowBlindAccessory.ts +53 -10
- package/src/homebridge-ui/public/index.html +219 -19
- package/src/homebridge-ui/server.ts +0 -34
- package/src/index.ts +4 -10
- package/src/irdevice/irdevice.ts +74 -35
- package/src/platform-hap.ts +365 -169
- package/src/platform-matter.ts +1923 -230
- package/src/settings.ts +78 -3
- package/src/test/apiRequestTracker.test.ts +417 -0
- package/src/test/hap/device-webhook-context.test.ts +136 -0
- package/src/test/hap/platform-hap.logging.test.ts +36 -0
- package/src/test/hap/platform-hap.test.ts +70 -0
- package/src/test/helpers/platform-fixtures.ts +33 -0
- package/src/test/homebridge-ui/server.test.ts +486 -0
- package/src/test/index.test.ts +24 -0
- package/src/test/matter/devices-matter/baseMatterAccessory.test.ts +88 -0
- package/src/test/matter/platform-matter.additional.test.ts +44 -0
- package/src/test/matter/platform-matter.bleparse.test.ts +47 -0
- package/src/test/matter/platform-matter.cleanup.test.ts +86 -0
- package/src/test/matter/platform-matter.keepstale.test.ts +37 -0
- package/src/test/matter/platform-matter.logging.test.ts +33 -0
- package/src/test/matter/platform-matter.mapping.test.ts +57 -0
- package/src/test/matter/platform-matter.openapi-mapping.test.ts +109 -0
- package/src/test/matter/platform-matter.test.ts +144 -0
- package/src/test/matter/platform-matter.unregister.test.ts +39 -0
- package/src/test/matter/platform-matter.webhook.test.ts +54 -0
- package/src/test/utils.test.ts +96 -0
- package/src/{verifyconfig.test.ts → test/verifyconfig.test.ts} +12 -11
- package/src/utils.ts +777 -36
- package/dist/index.test.js +0 -14
- package/dist/index.test.js.map +0 -1
- package/dist/verifyconfig.test.d.ts.map +0 -1
- package/dist/verifyconfig.test.js.map +0 -1
- package/src/index.test.ts +0 -19
- /package/dist/{index.test.d.ts → test/index.test.d.ts} +0 -0
- /package/dist/{verifyconfig.test.d.ts → test/verifyconfig.test.d.ts} +0 -0
package/dist/platform-matter.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import asyncmqtt from 'async-mqtt';
|
|
1
4
|
import { SwitchBotBLE, SwitchBotOpenAPI } from 'node-switchbot';
|
|
2
5
|
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
6
|
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
4
|
-
import {
|
|
5
|
-
/**
|
|
6
|
-
* MatterPlatform
|
|
7
|
-
* Demonstrates all available Matter device types in Homebridge
|
|
8
|
-
*
|
|
9
|
-
* Organized by official Matter Specification v1.4.1 categories
|
|
10
|
-
*/
|
|
7
|
+
import { ApiRequestTracker, applyDeviceTypeTemplates, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, isSuccessfulStatusCode, makeBLESender, makeOpenAPISender, mergeByDeviceId, normalizeDeviceId, rgb2hs, sleep } from './utils.js';
|
|
11
8
|
export class SwitchBotMatterPlatform {
|
|
12
9
|
log;
|
|
13
10
|
config;
|
|
@@ -22,32 +19,80 @@ export class SwitchBotMatterPlatform {
|
|
|
22
19
|
switchBotBLE;
|
|
23
20
|
// discovered devices cache
|
|
24
21
|
discoveredDevices = [];
|
|
22
|
+
// discovered IR devices cache
|
|
23
|
+
discoveredIRDevices = [];
|
|
24
|
+
// Registry of created accessory instances keyed by normalized deviceId
|
|
25
|
+
accessoryInstances = new Map();
|
|
26
|
+
// Refresh timers keyed by normalized deviceId
|
|
27
|
+
refreshTimers = new Map();
|
|
28
|
+
// Platform-level refresh timer for batch status updates
|
|
29
|
+
platformRefreshTimer;
|
|
30
|
+
// Device status cache from last refresh
|
|
31
|
+
deviceStatusCache = new Map();
|
|
32
|
+
// Backoff cooldowns persisted across restarts: normalized deviceId -> nextAllowedAt(ms)
|
|
33
|
+
backoffCooldowns = new Map();
|
|
34
|
+
backoffFilePath;
|
|
35
|
+
// Devices that have explicit per-device refresh timers (normalized deviceId)
|
|
36
|
+
perDeviceRefreshSet = new Set();
|
|
25
37
|
// BLE event handlers keyed by device MAC (formatted)
|
|
26
38
|
bleEventHandler = {};
|
|
39
|
+
// MQTT and Webhook properties (mirror HAP behavior so Matter platform can
|
|
40
|
+
// receive OpenAPI webhook events and/or MQTT-proxied webhook messages)
|
|
41
|
+
mqttClient = null;
|
|
42
|
+
webhookEventListener = null;
|
|
43
|
+
webhookEventHandler = {};
|
|
44
|
+
// Platform logging toggle (can be controlled via UI or config)
|
|
45
|
+
// Use same shape as HAP platform: string values like 'debug', 'debugMode', 'standard', or 'none'
|
|
46
|
+
platformLogging;
|
|
47
|
+
// API request tracking (persistent across restarts)
|
|
48
|
+
apiTracker;
|
|
49
|
+
// Platform-provided logging helpers (attached in constructor)
|
|
50
|
+
infoLog;
|
|
51
|
+
successLog;
|
|
52
|
+
debugSuccessLog;
|
|
53
|
+
warnLog;
|
|
54
|
+
debugWarnLog;
|
|
55
|
+
errorLog;
|
|
56
|
+
debugErrorLog;
|
|
57
|
+
debugLog;
|
|
58
|
+
loggingIsDebug;
|
|
59
|
+
enablingPlatformLogging;
|
|
27
60
|
constructor(log, config, api) {
|
|
28
61
|
this.log = log;
|
|
29
62
|
this.config = config;
|
|
30
63
|
this.api = api;
|
|
31
|
-
|
|
32
|
-
//
|
|
64
|
+
// Determine platform logging preference (match HAP behaviour as closely as
|
|
65
|
+
// possible using config values. We default to 'standard' when unspecified.)
|
|
66
|
+
this.platformLogging = (this.config.options?.logging === 'debug' || this.config.options?.logging === 'standard' || this.config.options?.logging === 'none')
|
|
67
|
+
? this.config.options.logging
|
|
68
|
+
: 'standard';
|
|
69
|
+
// Unconditional diagnostic using the raw Homebridge `log` so it always
|
|
70
|
+
// appears regardless of the platform logging helpers' gating logic.
|
|
33
71
|
try {
|
|
34
|
-
|
|
35
|
-
const cleaned = cleanDeviceConfig(this.config.options.deviceConfig);
|
|
36
|
-
if (cleaned) {
|
|
37
|
-
;
|
|
38
|
-
this.config.options.deviceConfig = cleaned;
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
delete this.config.options.deviceConfig;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
72
|
+
this.log.debug?.(`[SwitchBot Matter] effective platformLogging=${String(this.platformLogging)}`);
|
|
44
73
|
}
|
|
45
74
|
catch (e) {
|
|
46
|
-
|
|
75
|
+
// swallow any logging errors — diagnostics are best-effort
|
|
47
76
|
}
|
|
77
|
+
// Attach platform-wide logging helpers from utils so Matter and device
|
|
78
|
+
// classes can use consistent logging methods (infoLog/debugLog/etc.)
|
|
79
|
+
const _pl = createPlatformLogger(async () => this.platformLogging, this.log);
|
|
80
|
+
this.infoLog = _pl.infoLog;
|
|
81
|
+
this.successLog = _pl.successLog;
|
|
82
|
+
this.debugSuccessLog = _pl.debugSuccessLog;
|
|
83
|
+
this.warnLog = _pl.warnLog;
|
|
84
|
+
this.debugWarnLog = _pl.debugWarnLog;
|
|
85
|
+
this.errorLog = _pl.errorLog;
|
|
86
|
+
this.debugErrorLog = _pl.debugErrorLog;
|
|
87
|
+
this.debugLog = _pl.debugLog;
|
|
88
|
+
this.loggingIsDebug = _pl.loggingIsDebug;
|
|
89
|
+
this.enablingPlatformLogging = _pl.enablingPlatformLogging;
|
|
90
|
+
this.debugLog('Finished initializing platform:', this.config.name);
|
|
91
|
+
// Note: deviceConfig and irdeviceConfig have been removed from the platform.
|
|
92
|
+
// All device-specific configuration should be done via options.devices arrays.
|
|
48
93
|
// Does the user have a version of Homebridge that is compatible with matter?
|
|
49
94
|
if (!this.api.isMatterAvailable?.()) {
|
|
50
|
-
this.
|
|
95
|
+
this.warnLog('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.');
|
|
51
96
|
}
|
|
52
97
|
// Check if the user has matter enabled, this means:
|
|
53
98
|
// - If the plugin is running on the main bridge, then the user must have enabled matter in the Homebridge settings page in the UI
|
|
@@ -55,36 +100,64 @@ export class SwitchBotMatterPlatform {
|
|
|
55
100
|
// In reality, only the below check is needed, but they are both included here for completeness
|
|
56
101
|
// Remember to use a '?.' optional chaining operator in case the user is running an older version of Homebridge that does not have these APIs
|
|
57
102
|
if (!this.api.isMatterEnabled?.()) {
|
|
58
|
-
this.
|
|
103
|
+
this.warnLog('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.');
|
|
59
104
|
return;
|
|
60
105
|
}
|
|
61
106
|
// Register Matter accessories when Homebridge has finished launching
|
|
62
107
|
this.api.on('didFinishLaunching', () => {
|
|
63
|
-
this.
|
|
108
|
+
this.debugLog('Executed didFinishLaunching callback');
|
|
109
|
+
// Log presence of credentials (do not log actual values)
|
|
110
|
+
try {
|
|
111
|
+
this.debugLog(`SwitchBot credentials present? token=${Boolean(this.config.credentials?.token)}, secret=${Boolean(this.config.credentials?.secret)}`);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
this.debugLog('Failed to log credentials presence: %s', e?.message ?? e);
|
|
115
|
+
}
|
|
116
|
+
// Do not fall back to environment variables here — credentials must
|
|
117
|
+
// come from plugin config (Homebridge UI). If credentials appear
|
|
118
|
+
// missing we'll log that fact and continue; the UI should store token
|
|
119
|
+
// and secret in `config.credentials`.
|
|
64
120
|
// Initialize SwitchBot API clients
|
|
65
121
|
try {
|
|
66
122
|
if (this.config.credentials?.token && this.config.credentials?.secret) {
|
|
67
123
|
this.switchBotAPI = new SwitchBotOpenAPI(this.config.credentials.token, this.config.credentials.secret, this.config.options?.hostname);
|
|
68
124
|
// forward basic logs
|
|
69
125
|
if (!this.config.options?.disableLogsforOpenAPI && this.switchBotAPI?.on) {
|
|
70
|
-
this.switchBotAPI.on('log', (l) => this.
|
|
126
|
+
this.switchBotAPI.on('log', (l) => this.debugLog('[SwitchBot OpenAPI]', l.message));
|
|
71
127
|
}
|
|
72
128
|
}
|
|
73
129
|
else {
|
|
74
|
-
this.
|
|
130
|
+
this.debugLog('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped');
|
|
75
131
|
}
|
|
76
132
|
}
|
|
77
133
|
catch (e) {
|
|
78
|
-
this.
|
|
134
|
+
this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e);
|
|
135
|
+
}
|
|
136
|
+
// Initialize API request tracking
|
|
137
|
+
if (this.switchBotAPI) {
|
|
138
|
+
try {
|
|
139
|
+
this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter', {
|
|
140
|
+
dailyLimit: this.config.options?.dailyApiLimit ?? 10000,
|
|
141
|
+
reserveForCommands: this.config.options?.dailyApiReserveForCommands ?? 1000,
|
|
142
|
+
pausePollingAtReserve: this.config.options?.webhookOnlyOnReserve ?? false,
|
|
143
|
+
resetAtLocalMidnight: this.config.options?.dailyApiResetAtLocalMidnight ?? false,
|
|
144
|
+
});
|
|
145
|
+
this.apiTracker.startHourlyLogging();
|
|
146
|
+
// Parity with HAP platform: indicate successful initialization of API tracking (Matter-specific wording)
|
|
147
|
+
this.debugLog('API request tracking initialized (Matter platform, OpenAPI)');
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
this.errorLog('Failed to initialize API request tracking:', e?.message ?? e);
|
|
151
|
+
}
|
|
79
152
|
}
|
|
80
153
|
try {
|
|
81
154
|
this.switchBotBLE = new SwitchBotBLE();
|
|
82
155
|
if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
|
|
83
|
-
this.switchBotBLE.on('log', (l) => this.
|
|
156
|
+
this.switchBotBLE.on('log', (l) => this.debugLog('[SwitchBot BLE]', l.message));
|
|
84
157
|
}
|
|
85
158
|
}
|
|
86
159
|
catch (e) {
|
|
87
|
-
this.
|
|
160
|
+
this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e);
|
|
88
161
|
}
|
|
89
162
|
// If BLE scanning is enabled, start scanning and route advertisements to registered handlers
|
|
90
163
|
if (this.config.options?.BLE && this.switchBotBLE) {
|
|
@@ -94,7 +167,7 @@ export class SwitchBotMatterPlatform {
|
|
|
94
167
|
await ble.startScan();
|
|
95
168
|
}
|
|
96
169
|
catch (e) {
|
|
97
|
-
this.
|
|
170
|
+
this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`);
|
|
98
171
|
}
|
|
99
172
|
// route advertisements to our handlers
|
|
100
173
|
ble.onadvertisement = async (ad) => {
|
|
@@ -106,15 +179,277 @@ export class SwitchBotMatterPlatform {
|
|
|
106
179
|
}
|
|
107
180
|
}
|
|
108
181
|
catch (e) {
|
|
109
|
-
this.
|
|
182
|
+
this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`);
|
|
110
183
|
}
|
|
111
184
|
};
|
|
112
185
|
})();
|
|
113
186
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
187
|
+
// Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
|
|
188
|
+
this.api.on('shutdown', async () => {
|
|
189
|
+
try {
|
|
190
|
+
this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers');
|
|
191
|
+
// Stop API tracking hourly logging
|
|
192
|
+
if (this.apiTracker) {
|
|
193
|
+
this.apiTracker.stopHourlyLogging();
|
|
194
|
+
}
|
|
195
|
+
// Clear all refresh timers
|
|
196
|
+
for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
|
|
197
|
+
try {
|
|
198
|
+
clearInterval(t);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`);
|
|
202
|
+
}
|
|
203
|
+
this.refreshTimers.delete(nid);
|
|
204
|
+
}
|
|
205
|
+
// Clear platform-level refresh timer
|
|
206
|
+
try {
|
|
207
|
+
if (this.platformRefreshTimer) {
|
|
208
|
+
clearInterval(this.platformRefreshTimer);
|
|
209
|
+
this.platformRefreshTimer = undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`);
|
|
214
|
+
}
|
|
215
|
+
// Clear accessory instances registry
|
|
216
|
+
try {
|
|
217
|
+
this.accessoryInstances.clear();
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`);
|
|
221
|
+
}
|
|
222
|
+
// Remove BLE handlers
|
|
223
|
+
try {
|
|
224
|
+
for (const k of Object.keys(this.bleEventHandler)) {
|
|
225
|
+
delete this.bleEventHandler[k];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`);
|
|
230
|
+
}
|
|
231
|
+
// Stop BLE scanning if available
|
|
232
|
+
try {
|
|
233
|
+
if (this.switchBotBLE && typeof this.switchBotBLE.stopScan === 'function') {
|
|
234
|
+
await this.switchBotBLE.stopScan();
|
|
235
|
+
this.infoLog('Stopped BLE scanning');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`);
|
|
240
|
+
}
|
|
241
|
+
// Persist backoff cooldowns to disk
|
|
242
|
+
try {
|
|
243
|
+
if (this.backoffFilePath) {
|
|
244
|
+
const obj = {};
|
|
245
|
+
for (const [k, v] of this.backoffCooldowns.entries()) {
|
|
246
|
+
if (Number.isFinite(v)) {
|
|
247
|
+
obj[k] = v;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
writeFileSync(this.backoffFilePath, JSON.stringify(obj, null, 2));
|
|
251
|
+
this.debugLog(`Saved ${Object.keys(obj).length} backoff cooldown entries`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
this.debugLog(`Failed to save backoff state: ${e?.message ?? e}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
(async () => {
|
|
263
|
+
try {
|
|
264
|
+
await this.discoverDevices();
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
this.debugLog('Device discovery failed during startup: %s', e?.message ?? e);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await this.registerMatterAccessories();
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e);
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
});
|
|
277
|
+
try {
|
|
278
|
+
this.setupMqtt();
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
this.errorLog(`Setup MQTT, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
this.setupwebhook();
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
this.errorLog(`Setup Webhook, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
288
|
+
}
|
|
289
|
+
// Initialize backoff persistence file path and load any saved cooldowns
|
|
290
|
+
try {
|
|
291
|
+
const storagePath = this.api.user.storagePath();
|
|
292
|
+
this.backoffFilePath = join(storagePath, `${PLUGIN_NAME.replace('@', '').replace('/', '-')}-matter-backoff.json`);
|
|
293
|
+
if (existsSync(this.backoffFilePath)) {
|
|
294
|
+
try {
|
|
295
|
+
const raw = readFileSync(this.backoffFilePath, 'utf8');
|
|
296
|
+
const parsed = JSON.parse(raw);
|
|
297
|
+
if (parsed && typeof parsed === 'object') {
|
|
298
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
299
|
+
const ts = Number(v);
|
|
300
|
+
if (Number.isFinite(ts)) {
|
|
301
|
+
this.backoffCooldowns.set(String(k), ts);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.debugLog(`Loaded ${this.backoffCooldowns.size} backoff cooldown entries`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
this.debugLog(`Failed to load backoff state: ${e?.message ?? e}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (e) {
|
|
313
|
+
this.debugLog(`Failed to initialize backoff persistence: ${e?.message ?? e}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Normalize a deviceId for matching (uppercase alphanumerics only)
|
|
318
|
+
* @deprecated Use shared normalizeDeviceId from utils.js instead
|
|
319
|
+
*/
|
|
320
|
+
normalizeDeviceId(deviceId) {
|
|
321
|
+
return normalizeDeviceId(deviceId);
|
|
322
|
+
}
|
|
323
|
+
/** Determine the platform-level batch interval in seconds */
|
|
324
|
+
getPlatformBatchInterval() {
|
|
325
|
+
const opt = this.config.options;
|
|
326
|
+
const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300;
|
|
327
|
+
const n = Number(val);
|
|
328
|
+
return Number.isFinite(n) && n > 0 ? n : 300;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
|
|
332
|
+
*/
|
|
333
|
+
clearDeviceResources(deviceId) {
|
|
334
|
+
if (!deviceId) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
339
|
+
const existing = this.refreshTimers.get(nid);
|
|
340
|
+
if (existing) {
|
|
341
|
+
try {
|
|
342
|
+
clearInterval(existing);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`);
|
|
346
|
+
}
|
|
347
|
+
this.refreshTimers.delete(nid);
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
this.accessoryInstances.delete(nid);
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`);
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const mac = formatDeviceIdAsMac(deviceId).toLowerCase();
|
|
357
|
+
if (this.bleEventHandler[mac]) {
|
|
358
|
+
delete this.bleEventHandler[mac];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
// formatting failed (not a MAC-like id) — ignore
|
|
363
|
+
this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Merge discovered devices with per-device overrides from `config.options.devices`.
|
|
372
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
373
|
+
*/
|
|
374
|
+
async mergeDiscoveredDevices(discovered) {
|
|
375
|
+
// If there's no per-device config, return discovered as-is
|
|
376
|
+
if (!this.config.options?.devices) {
|
|
377
|
+
return discovered;
|
|
378
|
+
}
|
|
379
|
+
// Assign missing deviceType from configDeviceType if needed
|
|
380
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
381
|
+
if (!deviceObj.deviceType) {
|
|
382
|
+
deviceObj.deviceType = deviceObj.configDeviceType !== undefined ? deviceObj.configDeviceType : 'Unknown';
|
|
383
|
+
this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${deviceObj.configDeviceType}`);
|
|
384
|
+
}
|
|
385
|
+
return deviceObj;
|
|
117
386
|
});
|
|
387
|
+
// Apply device-type templates from config entries with applyToAllDevicesOfType=true
|
|
388
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(devicesWithTypeAssigned, this.config.options.devices, 'deviceType', msg => this.debugLog(msg));
|
|
389
|
+
// Merge per-device overrides by matching deviceId
|
|
390
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
391
|
+
const merged = mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTemplates ?? [], allowConfigOnly);
|
|
392
|
+
// Apply global webhook setting if not explicitly set on individual devices
|
|
393
|
+
if (this.config.options?.webhook === true) {
|
|
394
|
+
merged.forEach((device) => {
|
|
395
|
+
if (device.webhook === undefined) {
|
|
396
|
+
device.webhook = true;
|
|
397
|
+
this.debugLog(`Applied global webhook setting to Matter device: ${device.deviceId}`);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return merged;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Merge discovered IR devices with per-device overrides from `config.options.irdevices`.
|
|
405
|
+
* Also applies device-type templates when applyToAllDevicesOfType is set.
|
|
406
|
+
*/
|
|
407
|
+
async mergeDiscoveredIRDevices(discovered) {
|
|
408
|
+
// If there's no per-device config, return discovered as-is
|
|
409
|
+
if (!this.config.options?.irdevices) {
|
|
410
|
+
return discovered;
|
|
411
|
+
}
|
|
412
|
+
// Assign missing remoteType from configRemoteType if needed
|
|
413
|
+
const devicesWithTypeAssigned = discovered.map((deviceObj) => {
|
|
414
|
+
if (!deviceObj.remoteType && deviceObj.configRemoteType) {
|
|
415
|
+
deviceObj.remoteType = deviceObj.configRemoteType;
|
|
416
|
+
this.debugLog(`API missing remoteType for ${deviceObj.deviceId}, using configRemoteType: ${deviceObj.configRemoteType}`);
|
|
417
|
+
}
|
|
418
|
+
else if (!deviceObj.remoteType) {
|
|
419
|
+
this.warnLog(`No remoteType for IR device ${deviceObj.deviceId}, skipping`);
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
return deviceObj;
|
|
423
|
+
}).filter(device => device !== null);
|
|
424
|
+
// Apply remote-type templates from config entries with applyToAllDevicesOfType=true
|
|
425
|
+
const devicesWithTemplates = applyDeviceTypeTemplates(devicesWithTypeAssigned, this.config.options.irdevices, 'remoteType', msg => this.debugLog(msg));
|
|
426
|
+
// Merge per-device overrides by matching deviceId
|
|
427
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
428
|
+
const merged = mergeByDeviceId(this.config.options?.irdevices ?? [], devicesWithTemplates ?? [], allowConfigOnly);
|
|
429
|
+
// Apply global webhook setting if not explicitly set on individual IR devices
|
|
430
|
+
if (this.config.options?.webhook === true) {
|
|
431
|
+
merged.forEach((device) => {
|
|
432
|
+
if (device.webhook === undefined) {
|
|
433
|
+
device.webhook = true;
|
|
434
|
+
this.debugLog(`Applied global webhook setting to Matter IR device: ${device.deviceId}`);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return merged;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Select effective connection type for a device: prefer explicit device.connectionType,
|
|
442
|
+
* otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
|
|
443
|
+
*/
|
|
444
|
+
chooseConnectionType(deviceObj) {
|
|
445
|
+
if (deviceObj?.connectionType) {
|
|
446
|
+
return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI';
|
|
447
|
+
}
|
|
448
|
+
// If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
|
|
449
|
+
if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
|
|
450
|
+
return 'BLE';
|
|
451
|
+
}
|
|
452
|
+
return 'OpenAPI';
|
|
118
453
|
}
|
|
119
454
|
/**
|
|
120
455
|
* Map a SwitchBot device object to a MatterAccessory using the device-specific
|
|
@@ -125,7 +460,7 @@ export class SwitchBotMatterPlatform {
|
|
|
125
460
|
const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device';
|
|
126
461
|
const serial = dev.deviceId ?? 'unknown';
|
|
127
462
|
const manufacturer = 'SwitchBot';
|
|
128
|
-
const model = dev.deviceType ?? 'SwitchBot';
|
|
463
|
+
const model = dev.model ?? dev.deviceType ?? 'SwitchBot';
|
|
129
464
|
const firmware = dev.firmware ?? dev.version ?? '0.0.0';
|
|
130
465
|
// Helper to build a default opts object consumed by the matter device classes
|
|
131
466
|
const baseOpts = {
|
|
@@ -137,52 +472,46 @@ export class SwitchBotMatterPlatform {
|
|
|
137
472
|
firmwareRevision: String(firmware),
|
|
138
473
|
hardwareRevision: '1.0.0',
|
|
139
474
|
deviceId: dev.deviceId,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const maxRetries = this.config.options?.bleRetries ?? 2;
|
|
156
|
-
const retryDelay = this.config.options?.bleRetryDelay ?? 500;
|
|
157
|
-
let attempt = 0;
|
|
158
|
-
while (attempt < maxRetries) {
|
|
159
|
-
try {
|
|
160
|
-
const list = await this.switchBotBLE.discover({ model: dev.bleModel, id });
|
|
161
|
-
if (!Array.isArray(list) || list.length === 0) {
|
|
162
|
-
throw new Error('BLE device not found');
|
|
163
|
-
}
|
|
164
|
-
const deviceInst = list[0];
|
|
165
|
-
if (typeof deviceInst[methodName] !== 'function') {
|
|
166
|
-
throw new TypeError(`BLE method ${methodName} not available on device`);
|
|
167
|
-
}
|
|
168
|
-
return await deviceInst[methodName](...args);
|
|
169
|
-
}
|
|
170
|
-
catch (e) {
|
|
171
|
-
attempt++;
|
|
172
|
-
if (attempt >= maxRetries) {
|
|
173
|
-
throw e;
|
|
174
|
-
}
|
|
175
|
-
this.log.debug(`BLE ${methodName} attempt ${attempt} failed for ${dev.deviceId}: ${e?.message ?? e}, retrying in ${retryDelay}ms`);
|
|
176
|
-
await sleep(retryDelay);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
throw new Error('BLE operation failed');
|
|
475
|
+
// Inject handy platform-side helpers into the accessory `context` so Matter
|
|
476
|
+
// accessory classes can perform OpenAPI/BLE actions without reaching into
|
|
477
|
+
// the platform implementation directly.
|
|
478
|
+
context: {
|
|
479
|
+
deviceId: dev.deviceId,
|
|
480
|
+
// Expose the display name so Matter accessory classes can read it from context
|
|
481
|
+
name: displayName,
|
|
482
|
+
// Provide device-level logging override (if present) and platform logging flag
|
|
483
|
+
// so accessories can decide how verbose they should be.
|
|
484
|
+
deviceLogging: dev?.logging,
|
|
485
|
+
platformLogging: this.platformLogging,
|
|
486
|
+
// Expose effective webhook setting for parity with HAP base device logic
|
|
487
|
+
// Prefer explicit per-device setting; otherwise fall back to global option
|
|
488
|
+
webhook: dev?.webhook !== undefined ? dev.webhook : (this.config.options?.webhook === true ? true : undefined),
|
|
489
|
+
},
|
|
180
490
|
};
|
|
181
|
-
|
|
491
|
+
// Log effective webhook for diagnostics
|
|
492
|
+
try {
|
|
493
|
+
const effectiveWebhook = dev?.webhook !== undefined ? dev.webhook : (this.config.options?.webhook === true ? true : undefined);
|
|
494
|
+
this.debugLog(`Effective webhook for Matter device ${displayName} (${dev.deviceId}): ${String(effectiveWebhook)}`);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
this.debugLog(`Failed logging effective webhook for Matter device ${displayName} (${dev.deviceId}): ${e?.message ?? e}`);
|
|
498
|
+
}
|
|
499
|
+
// Build platform-side helpers using shared factories so they can be reused/tested
|
|
500
|
+
const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 });
|
|
501
|
+
const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: this.config.options?.bleRetries ?? 2, bleRetryDelay: this.config.options?.bleRetryDelay ?? 500 });
|
|
502
|
+
// Log that we're initializing this device so it's visible in startup logs
|
|
503
|
+
try {
|
|
504
|
+
this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
// best-effort logging — swallow errors to avoid breaking initialization
|
|
508
|
+
this.debugLog('Failed to log initializing device:', e?.message ?? e);
|
|
509
|
+
}
|
|
510
|
+
const makeOnOffHandlers = (uuid, connectionType) => ({
|
|
182
511
|
onOff: {
|
|
183
512
|
on: async () => {
|
|
184
513
|
try {
|
|
185
|
-
if (
|
|
514
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
186
515
|
await sendBLE('turnOn');
|
|
187
516
|
}
|
|
188
517
|
else {
|
|
@@ -191,12 +520,12 @@ export class SwitchBotMatterPlatform {
|
|
|
191
520
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true });
|
|
192
521
|
}
|
|
193
522
|
catch (e) {
|
|
194
|
-
this.
|
|
523
|
+
this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
195
524
|
}
|
|
196
525
|
},
|
|
197
526
|
off: async () => {
|
|
198
527
|
try {
|
|
199
|
-
if (
|
|
528
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
200
529
|
await sendBLE('turnOff');
|
|
201
530
|
}
|
|
202
531
|
else {
|
|
@@ -205,22 +534,28 @@ export class SwitchBotMatterPlatform {
|
|
|
205
534
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false });
|
|
206
535
|
}
|
|
207
536
|
catch (e) {
|
|
208
|
-
this.
|
|
537
|
+
this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
209
538
|
}
|
|
210
539
|
},
|
|
211
540
|
},
|
|
212
541
|
});
|
|
213
|
-
// Mapping from SwitchBot deviceType -> constructor
|
|
542
|
+
// Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
|
|
214
543
|
const mapping = {
|
|
215
544
|
// Plugs / Outlets
|
|
216
545
|
'Plug': OnOffOutletAccessory,
|
|
217
546
|
'Plug Mini (US)': OnOffOutletAccessory,
|
|
218
547
|
'Plug Mini (JP)': OnOffOutletAccessory,
|
|
548
|
+
'Plug Mini': OnOffOutletAccessory,
|
|
549
|
+
'WoPlug': OnOffOutletAccessory,
|
|
219
550
|
// Lighting
|
|
220
551
|
'Color Bulb': ColorLightAccessory,
|
|
552
|
+
'Color Bulb Mini': ColorLightAccessory,
|
|
221
553
|
'Ceiling Light': ColorTemperatureLightAccessory,
|
|
222
554
|
'Ceiling Light Pro': ColorTemperatureLightAccessory,
|
|
223
555
|
'Strip Light': ExtendedColorLightAccessory,
|
|
556
|
+
'Light Strip': ExtendedColorLightAccessory,
|
|
557
|
+
'Light Strip Plus': ExtendedColorLightAccessory,
|
|
558
|
+
'Strip Light Pro': ExtendedColorLightAccessory,
|
|
224
559
|
'Dimmable Light': DimmableLightAccessory,
|
|
225
560
|
// Robot Vacuums
|
|
226
561
|
'K10+': RoboticVacuumAccessory,
|
|
@@ -230,49 +565,65 @@ export class SwitchBotMatterPlatform {
|
|
|
230
565
|
'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
|
|
231
566
|
'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
|
|
232
567
|
'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
|
|
568
|
+
'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
|
|
569
|
+
'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
|
|
233
570
|
// Locks
|
|
234
571
|
'Smart Lock': DoorLockAccessory,
|
|
235
572
|
'Smart Lock Pro': DoorLockAccessory,
|
|
573
|
+
'Smart Lock Ultra': DoorLockAccessory,
|
|
236
574
|
// Sensors
|
|
237
575
|
'Motion Sensor': OccupancySensorAccessory,
|
|
238
576
|
'Contact Sensor': ContactSensorAccessory,
|
|
239
577
|
'Water Detector': LeakSensorAccessory,
|
|
240
|
-
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'
|
|
244
|
-
'
|
|
578
|
+
// Meter devices should expose both temperature and humidity sensors
|
|
579
|
+
'Meter': [TemperatureSensorAccessory, HumiditySensorAccessory],
|
|
580
|
+
'MeterPlus': [TemperatureSensorAccessory, HumiditySensorAccessory],
|
|
581
|
+
'MeterPro': [TemperatureSensorAccessory, HumiditySensorAccessory],
|
|
582
|
+
'WoIOSensor': [TemperatureSensorAccessory, HumiditySensorAccessory],
|
|
583
|
+
'Air Purifier PM2.5': [HumiditySensorAccessory],
|
|
584
|
+
'Air Purifier Table PM2.5': [HumiditySensorAccessory],
|
|
585
|
+
'Air Purifier': [HumiditySensorAccessory],
|
|
586
|
+
'Air Purifier VOC': [HumiditySensorAccessory],
|
|
587
|
+
'Air Purifier Table VOC': [HumiditySensorAccessory],
|
|
245
588
|
// Fans
|
|
246
589
|
'Battery Circulator Fan': FanAccessory,
|
|
247
590
|
// Curtains / Blinds
|
|
248
591
|
'Blind Tilt': VenetianBlindAccessory,
|
|
249
592
|
'Curtain': WindowBlindAccessory,
|
|
593
|
+
'Curtain2': WindowBlindAccessory,
|
|
250
594
|
'Curtain3': WindowBlindAccessory,
|
|
595
|
+
'Curtain 2': WindowBlindAccessory,
|
|
251
596
|
'WoRollerShade': WindowBlindAccessory,
|
|
252
597
|
'Roller Shade': WindowBlindAccessory,
|
|
598
|
+
'Venetian Blind': VenetianBlindAccessory,
|
|
253
599
|
// Switches / Relays
|
|
254
600
|
'Relay Switch 1': OnOffSwitchAccessory,
|
|
255
601
|
'Relay Switch 1PM': OnOffSwitchAccessory,
|
|
256
|
-
|
|
257
|
-
'
|
|
258
|
-
|
|
259
|
-
'
|
|
260
|
-
'
|
|
261
|
-
'
|
|
262
|
-
'
|
|
263
|
-
'
|
|
264
|
-
'
|
|
602
|
+
'Relay Switch 2': OnOffSwitchAccessory,
|
|
603
|
+
'Relay Switch 3': OnOffSwitchAccessory,
|
|
604
|
+
// Misc / hubs / other
|
|
605
|
+
'Hub 2': [TemperatureSensorAccessory, HumiditySensorAccessory],
|
|
606
|
+
'Hub 3': [TemperatureSensorAccessory, HumiditySensorAccessory, OccupancySensorAccessory, LightSensorAccessory],
|
|
607
|
+
'Hub Mini': undefined,
|
|
608
|
+
'Bot': OnOffSwitchAccessory,
|
|
609
|
+
'Smart Bot': OnOffSwitchAccessory,
|
|
610
|
+
'Humidifier': [HumiditySensorAccessory],
|
|
611
|
+
'Humidifier2': [HumiditySensorAccessory],
|
|
612
|
+
'Thermostat': ThermostatAccessory,
|
|
613
|
+
'Water Heater': ThermostatAccessory,
|
|
265
614
|
};
|
|
266
615
|
const Ctor = mapping[dev.deviceType ?? ''];
|
|
267
616
|
if (!Ctor) {
|
|
268
|
-
this.
|
|
617
|
+
this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`);
|
|
269
618
|
return undefined;
|
|
270
619
|
}
|
|
271
620
|
// Build opts and handlers tailored for basic capabilities
|
|
272
621
|
const uuid = baseOpts.uuid;
|
|
273
622
|
const handlers = {};
|
|
623
|
+
// Choose connection type for this device (BLE vs OpenAPI)
|
|
624
|
+
const connectionType = this.chooseConnectionType(dev);
|
|
274
625
|
// On/Off common
|
|
275
|
-
handlers.onOff = makeOnOffHandlers(uuid).onOff;
|
|
626
|
+
handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff;
|
|
276
627
|
// If this is a light, add brightness and color handlers
|
|
277
628
|
if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
|
|
278
629
|
// levelControl
|
|
@@ -281,7 +632,7 @@ export class SwitchBotMatterPlatform {
|
|
|
281
632
|
try {
|
|
282
633
|
const level = request.level;
|
|
283
634
|
const percent = Math.round((level / 254) * 100);
|
|
284
|
-
if (
|
|
635
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
285
636
|
await sendBLE('setBrightness', percent);
|
|
286
637
|
}
|
|
287
638
|
else {
|
|
@@ -290,7 +641,7 @@ export class SwitchBotMatterPlatform {
|
|
|
290
641
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
291
642
|
}
|
|
292
643
|
catch (e) {
|
|
293
|
-
this.
|
|
644
|
+
this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
294
645
|
}
|
|
295
646
|
},
|
|
296
647
|
};
|
|
@@ -301,7 +652,7 @@ export class SwitchBotMatterPlatform {
|
|
|
301
652
|
const hue = request.hue;
|
|
302
653
|
const saturation = request.saturation;
|
|
303
654
|
const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100));
|
|
304
|
-
if (
|
|
655
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
305
656
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
306
657
|
}
|
|
307
658
|
else {
|
|
@@ -310,7 +661,7 @@ export class SwitchBotMatterPlatform {
|
|
|
310
661
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation });
|
|
311
662
|
}
|
|
312
663
|
catch (e) {
|
|
313
|
-
this.
|
|
664
|
+
this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
314
665
|
}
|
|
315
666
|
},
|
|
316
667
|
moveToColorLogic: async (request) => {
|
|
@@ -322,7 +673,7 @@ export class SwitchBotMatterPlatform {
|
|
|
322
673
|
const hueApprox = Math.round((colorX / 65535) * 360);
|
|
323
674
|
const satApprox = Math.round((colorY / 65535) * 100);
|
|
324
675
|
const [r, g, b] = hs2rgb(hueApprox, satApprox);
|
|
325
|
-
if (
|
|
676
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
326
677
|
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
327
678
|
}
|
|
328
679
|
else {
|
|
@@ -331,7 +682,7 @@ export class SwitchBotMatterPlatform {
|
|
|
331
682
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY });
|
|
332
683
|
}
|
|
333
684
|
catch (e) {
|
|
334
|
-
this.
|
|
685
|
+
this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
335
686
|
}
|
|
336
687
|
},
|
|
337
688
|
};
|
|
@@ -340,7 +691,7 @@ export class SwitchBotMatterPlatform {
|
|
|
340
691
|
moveToColorTemperature: async (request) => {
|
|
341
692
|
try {
|
|
342
693
|
const kelvin = Math.round(1000000 / Number(request.colorTemperature));
|
|
343
|
-
if (
|
|
694
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
344
695
|
await sendBLE('setColorTemperature', kelvin);
|
|
345
696
|
}
|
|
346
697
|
else {
|
|
@@ -349,14 +700,84 @@ export class SwitchBotMatterPlatform {
|
|
|
349
700
|
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 });
|
|
350
701
|
}
|
|
351
702
|
catch (e) {
|
|
352
|
-
this.
|
|
703
|
+
this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
// Ensure windowCovering.goToLiftPercentage handler for VenetianBlindAccessory and WindowBlindAccessory
|
|
709
|
+
if (['Venetian Blind', 'Blind Tilt'].includes(dev.deviceType ?? '')) {
|
|
710
|
+
handlers.windowCovering = {
|
|
711
|
+
goToLiftPercentage: async (request) => {
|
|
712
|
+
// Forward to accessory instance if available
|
|
713
|
+
const instance = this.accessoryInstances.get(normalizeDeviceId(dev.deviceId));
|
|
714
|
+
if (instance && typeof instance.handleGoToLift === 'function') {
|
|
715
|
+
return instance.handleGoToLift(request);
|
|
716
|
+
}
|
|
717
|
+
this.warnLog(`No instance or handleGoToLift for VenetianBlindAccessory deviceId=${dev.deviceId}`);
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
if ([
|
|
722
|
+
'Curtain',
|
|
723
|
+
'Curtain2',
|
|
724
|
+
'Curtain3',
|
|
725
|
+
'Curtain 2',
|
|
726
|
+
'WoRollerShade',
|
|
727
|
+
'Roller Shade',
|
|
728
|
+
].includes(dev.deviceType ?? '')) {
|
|
729
|
+
handlers.windowCovering = {
|
|
730
|
+
goToLiftPercentage: async (request) => {
|
|
731
|
+
// Forward to accessory instance if available
|
|
732
|
+
const instance = this.accessoryInstances.get(normalizeDeviceId(dev.deviceId));
|
|
733
|
+
if (instance && typeof instance.handleGoToLift === 'function') {
|
|
734
|
+
return instance.handleGoToLift(request);
|
|
353
735
|
}
|
|
736
|
+
this.warnLog(`No instance or handleGoToLift for WindowBlindAccessory deviceId=${dev.deviceId}`);
|
|
354
737
|
},
|
|
355
738
|
};
|
|
356
739
|
}
|
|
740
|
+
// Expose platform helpers to the accessory via context so accessory
|
|
741
|
+
// classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
|
|
742
|
+
// the effective connection type.
|
|
743
|
+
try {
|
|
744
|
+
/* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
|
|
745
|
+
into the accessory context so Matter accessory classes can use them without
|
|
746
|
+
reaching into the platform implementation directly. */
|
|
747
|
+
;
|
|
748
|
+
baseOpts.context = Object.assign({}, baseOpts.context, {
|
|
749
|
+
sendOpenAPI,
|
|
750
|
+
sendBLE,
|
|
751
|
+
connectionType,
|
|
752
|
+
// Expose platform logging helpers so accessories can use consistent logging
|
|
753
|
+
infoLog: this.infoLog,
|
|
754
|
+
debugLog: this.debugLog,
|
|
755
|
+
warnLog: this.warnLog,
|
|
756
|
+
errorLog: this.errorLog,
|
|
757
|
+
successLog: this.successLog,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e);
|
|
762
|
+
}
|
|
357
763
|
const opts = Object.assign({}, baseOpts, { handlers });
|
|
358
764
|
// Instantiate the device class and return its serialized accessory
|
|
359
765
|
const instance = new Ctor(this.api, this.log, opts);
|
|
766
|
+
// Save instance in registry so platform can call device-specific update methods if needed
|
|
767
|
+
try {
|
|
768
|
+
if (dev?.deviceId) {
|
|
769
|
+
this.accessoryInstances.set(normalizeDeviceId(dev.deviceId), instance);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
catch (e) {
|
|
773
|
+
this.debugLog('Failed to register accessory instance: %s', e?.message ?? e);
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
this.debugLog('Failed to log initialized accessory:', e?.message ?? e);
|
|
780
|
+
}
|
|
360
781
|
// Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
|
|
361
782
|
try {
|
|
362
783
|
const mac = formatDeviceIdAsMac(dev.deviceId).toLowerCase();
|
|
@@ -366,6 +787,13 @@ export class SwitchBotMatterPlatform {
|
|
|
366
787
|
// First try model-specific / normalized parsing of BLE advertisement
|
|
367
788
|
try {
|
|
368
789
|
const parsed = this.parseAdvertisementForDevice(dev, serviceData);
|
|
790
|
+
try {
|
|
791
|
+
const _p = JSON.stringify(parsed);
|
|
792
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: ${_p}`);
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: [unstringifiable parse result]`);
|
|
796
|
+
}
|
|
369
797
|
if (parsed) {
|
|
370
798
|
// Power
|
|
371
799
|
if (parsed.power !== undefined) {
|
|
@@ -373,14 +801,166 @@ export class SwitchBotMatterPlatform {
|
|
|
373
801
|
}
|
|
374
802
|
// Brightness
|
|
375
803
|
if (parsed.brightness !== undefined) {
|
|
376
|
-
const
|
|
804
|
+
const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254);
|
|
805
|
+
const level = Math.max(0, Math.min(254, rawLevel));
|
|
806
|
+
this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`);
|
|
377
807
|
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
378
808
|
}
|
|
379
809
|
// Color
|
|
380
810
|
if (parsed.color !== undefined) {
|
|
381
811
|
const { r, g, b } = parsed.color;
|
|
382
812
|
const [h, s] = rgb2hs(r, g, b);
|
|
383
|
-
|
|
813
|
+
const rawHue = Math.round((h / 360) * 254);
|
|
814
|
+
const rawSat = Math.round((s / 100) * 254);
|
|
815
|
+
const hue = Math.max(0, Math.min(254, rawHue));
|
|
816
|
+
const sat = Math.max(0, Math.min(254, rawSat));
|
|
817
|
+
this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`);
|
|
818
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat });
|
|
819
|
+
}
|
|
820
|
+
// Battery -> powerSource cluster (common mapping)
|
|
821
|
+
if (parsed.battery !== undefined) {
|
|
822
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
823
|
+
const deviceType = String(dev?.deviceType ?? '');
|
|
824
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt'];
|
|
825
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
826
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`);
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
try {
|
|
830
|
+
const percentage = Number(parsed.battery);
|
|
831
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
832
|
+
let batChargeLevel = 0;
|
|
833
|
+
if (percentage < 20) {
|
|
834
|
+
batChargeLevel = 2;
|
|
835
|
+
}
|
|
836
|
+
else if (percentage < 40) {
|
|
837
|
+
batChargeLevel = 1;
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel });
|
|
841
|
+
}
|
|
842
|
+
catch (updateError) {
|
|
843
|
+
// Silently skip if powerSource cluster doesn't exist on this device
|
|
844
|
+
const msg = String(updateError?.message ?? updateError);
|
|
845
|
+
if (!msg.includes('does not exist') && !msg.includes('not found')) {
|
|
846
|
+
throw updateError;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Temperature -> temperatureMeasurement
|
|
856
|
+
if (parsed.temperature !== undefined) {
|
|
857
|
+
try {
|
|
858
|
+
const c = Number(parsed.temperature);
|
|
859
|
+
const measured = Math.round(c * 100);
|
|
860
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured });
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// Humidity -> relativeHumidityMeasurement
|
|
867
|
+
if (parsed.humidity !== undefined) {
|
|
868
|
+
try {
|
|
869
|
+
const percent = Number(parsed.humidity);
|
|
870
|
+
const measured = Math.round(percent * 100);
|
|
871
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured });
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
// Contact / Leak -> BooleanState
|
|
878
|
+
if (parsed.contact !== undefined || parsed.leak !== undefined) {
|
|
879
|
+
try {
|
|
880
|
+
// Some devices report contact as true=open; ContactSensor expects inverted value
|
|
881
|
+
const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact);
|
|
882
|
+
const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak);
|
|
883
|
+
if (isContactOpen !== undefined) {
|
|
884
|
+
// If this is a contact sensor device type, invert; otherwise set conservatively
|
|
885
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
886
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen });
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (leakDetected !== undefined) {
|
|
893
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch (e) {
|
|
897
|
+
this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Motion -> occupancy
|
|
901
|
+
if (parsed.motion !== undefined) {
|
|
902
|
+
try {
|
|
903
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } });
|
|
904
|
+
}
|
|
905
|
+
catch (e) {
|
|
906
|
+
this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// Lock state -> doorLock
|
|
910
|
+
if (parsed.lock !== undefined) {
|
|
911
|
+
try {
|
|
912
|
+
const s = String(parsed.lock).toLowerCase();
|
|
913
|
+
let lockState = 0;
|
|
914
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
915
|
+
lockState = 1;
|
|
916
|
+
}
|
|
917
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
918
|
+
lockState = 2;
|
|
919
|
+
}
|
|
920
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState });
|
|
921
|
+
}
|
|
922
|
+
catch (e) {
|
|
923
|
+
this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// Position / Cover -> WindowCovering (convert open percent to closed*100)
|
|
927
|
+
if (parsed.position !== undefined) {
|
|
928
|
+
try {
|
|
929
|
+
const openPercent = Number(parsed.position);
|
|
930
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
931
|
+
const value = Math.round(closedPercent * 100);
|
|
932
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value });
|
|
933
|
+
}
|
|
934
|
+
catch (e) {
|
|
935
|
+
this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// Fan speed -> FanControl
|
|
939
|
+
if (parsed.fanSpeed !== undefined) {
|
|
940
|
+
try {
|
|
941
|
+
const percent = Number(parsed.fanSpeed);
|
|
942
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent });
|
|
943
|
+
}
|
|
944
|
+
catch (e) {
|
|
945
|
+
this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
|
|
949
|
+
if (parsed.rvcRunMode !== undefined) {
|
|
950
|
+
try {
|
|
951
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) });
|
|
952
|
+
}
|
|
953
|
+
catch (e) {
|
|
954
|
+
this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (parsed.rvcOperationalState !== undefined) {
|
|
958
|
+
try {
|
|
959
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) });
|
|
960
|
+
}
|
|
961
|
+
catch (e) {
|
|
962
|
+
this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
963
|
+
}
|
|
384
964
|
}
|
|
385
965
|
// If we parsed something from serviceData prefer it and return early
|
|
386
966
|
if (serviceData) {
|
|
@@ -389,57 +969,136 @@ export class SwitchBotMatterPlatform {
|
|
|
389
969
|
}
|
|
390
970
|
}
|
|
391
971
|
catch (e) {
|
|
392
|
-
this.
|
|
972
|
+
this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
393
973
|
}
|
|
394
974
|
// Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
|
|
395
975
|
if (!this.switchBotAPI) {
|
|
396
976
|
return;
|
|
397
977
|
}
|
|
398
978
|
try {
|
|
399
|
-
|
|
400
|
-
if (!(statusCode === 100 || statusCode === 200)) {
|
|
979
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
401
980
|
return;
|
|
402
981
|
}
|
|
982
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
403
983
|
const respAny = response;
|
|
404
984
|
const body = respAny?.body ?? respAny;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// Brightness
|
|
412
|
-
if (status?.brightness !== undefined) {
|
|
413
|
-
const level = Math.round((Number(status.brightness) / 100) * 254);
|
|
414
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
415
|
-
}
|
|
416
|
-
// Color
|
|
417
|
-
if (status?.color !== undefined) {
|
|
418
|
-
const color = String(status.color);
|
|
419
|
-
let r = 0;
|
|
420
|
-
let g = 0;
|
|
421
|
-
let b = 0;
|
|
422
|
-
if (color.includes(':')) {
|
|
423
|
-
const parts = color.split(':').map(Number);
|
|
424
|
-
[r, g, b] = parts;
|
|
425
|
-
}
|
|
426
|
-
else if (color.startsWith('#')) {
|
|
427
|
-
const hex = color.replace('#', '');
|
|
428
|
-
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
429
|
-
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
430
|
-
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
431
|
-
}
|
|
432
|
-
const [h, s] = rgb2hs(r, g, b);
|
|
433
|
-
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: Math.round((h / 360) * 254), currentSaturation: Math.round((s / 100) * 254) });
|
|
985
|
+
try {
|
|
986
|
+
const s = JSON.stringify(body);
|
|
987
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
988
|
+
}
|
|
989
|
+
catch (e) {
|
|
990
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
434
991
|
}
|
|
992
|
+
if (!isSuccessfulStatusCode(statusCode)) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const status = body?.status ?? body;
|
|
996
|
+
// Use centralized mapper which prefers accessory instance update helpers
|
|
997
|
+
await this.applyStatusWithRegistrationRetry(uuidLocal, dev, status);
|
|
435
998
|
}
|
|
436
999
|
catch (e) {
|
|
437
|
-
this.
|
|
1000
|
+
this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
438
1001
|
}
|
|
439
1002
|
};
|
|
440
1003
|
}
|
|
441
1004
|
catch (e) {
|
|
442
|
-
this.
|
|
1005
|
+
this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1006
|
+
}
|
|
1007
|
+
// Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
|
|
1008
|
+
try {
|
|
1009
|
+
const nid = this.normalizeDeviceId(dev.deviceId);
|
|
1010
|
+
// Clear any existing timer for this device
|
|
1011
|
+
const existing = this.refreshTimers.get(nid);
|
|
1012
|
+
if (existing) {
|
|
1013
|
+
clearInterval(existing);
|
|
1014
|
+
this.refreshTimers.delete(nid);
|
|
1015
|
+
}
|
|
1016
|
+
const platformInterval = this.getPlatformBatchInterval();
|
|
1017
|
+
const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0;
|
|
1018
|
+
if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
|
|
1019
|
+
// One-shot to populate initial state AFTER registration has likely completed
|
|
1020
|
+
// Defer slightly to avoid race where updateAccessoryState runs before registration
|
|
1021
|
+
;
|
|
1022
|
+
(async () => {
|
|
1023
|
+
await sleep(250);
|
|
1024
|
+
try {
|
|
1025
|
+
this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`);
|
|
1026
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
1027
|
+
this.warnLog(`Skipping initial OpenAPI refresh for ${dev.deviceId} due to daily budget`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
1031
|
+
const respAny = response;
|
|
1032
|
+
const body = respAny?.body ?? respAny;
|
|
1033
|
+
try {
|
|
1034
|
+
const s = JSON.stringify(body);
|
|
1035
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
1036
|
+
}
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
1039
|
+
}
|
|
1040
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
1041
|
+
const status = body?.status ?? body;
|
|
1042
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, status);
|
|
1043
|
+
this.infoLog(`Initial OpenAPI refresh succeeded for ${dev.deviceId}`);
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
this.warnLog(`Initial OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
catch (e) {
|
|
1050
|
+
this.errorLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1051
|
+
}
|
|
1052
|
+
})();
|
|
1053
|
+
if (hasDeviceInterval) {
|
|
1054
|
+
// Create a per-device timer and exclude it from batch
|
|
1055
|
+
const interval = Number(dev.refreshRate);
|
|
1056
|
+
this.perDeviceRefreshSet.add(nid);
|
|
1057
|
+
const timer = setInterval(async () => {
|
|
1058
|
+
try {
|
|
1059
|
+
// Skip if device is under cooldown
|
|
1060
|
+
const now = Date.now();
|
|
1061
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
1062
|
+
if (now < nextAllowed) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
await this.refreshSingleDeviceWithRetry(dev);
|
|
1066
|
+
}
|
|
1067
|
+
catch (e) {
|
|
1068
|
+
this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1069
|
+
}
|
|
1070
|
+
}, interval * 1000);
|
|
1071
|
+
this.refreshTimers.set(nid, timer);
|
|
1072
|
+
this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
// Start platform-level batched refresh timer (only once)
|
|
1076
|
+
this.startPlatformRefreshTimer(platformInterval);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1082
|
+
}
|
|
1083
|
+
// Register webhook handler for this device
|
|
1084
|
+
try {
|
|
1085
|
+
if (dev.webhook && dev.deviceId) {
|
|
1086
|
+
this.debugLog(`Registering webhook handler for Matter device: ${dev.deviceId}`);
|
|
1087
|
+
this.webhookEventHandler[dev.deviceId] = async (context) => {
|
|
1088
|
+
try {
|
|
1089
|
+
this.debugLog(`Received webhook for Matter device ${dev.deviceId}: ${JSON.stringify(context)}`);
|
|
1090
|
+
// Apply webhook status update to the accessory
|
|
1091
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, context);
|
|
1092
|
+
}
|
|
1093
|
+
catch (e) {
|
|
1094
|
+
this.errorLog(`Failed to handle webhook for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
this.debugSuccessLog(`Webhook handler registered for ${dev.deviceId}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
catch (e) {
|
|
1101
|
+
this.debugLog(`Failed to register webhook handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
443
1102
|
}
|
|
444
1103
|
return instance.toAccessory();
|
|
445
1104
|
}
|
|
@@ -448,43 +1107,139 @@ export class SwitchBotMatterPlatform {
|
|
|
448
1107
|
*/
|
|
449
1108
|
async discoverDevices() {
|
|
450
1109
|
if (!this.switchBotAPI) {
|
|
451
|
-
this.
|
|
1110
|
+
this.debugLog('SwitchBot OpenAPI not configured; skipping discovery');
|
|
452
1111
|
return;
|
|
453
1112
|
}
|
|
454
1113
|
try {
|
|
1114
|
+
if (!this.apiTracker?.trySpend('discovery')) {
|
|
1115
|
+
this.warnLog('OpenAPI daily budget reached; skipping Matter discovery');
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
455
1118
|
const { response, statusCode } = await this.switchBotAPI.getDevices();
|
|
456
|
-
this.
|
|
457
|
-
if (statusCode
|
|
1119
|
+
this.debugLog(`SwitchBot getDevices response status: ${statusCode}`);
|
|
1120
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
458
1121
|
const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : [];
|
|
459
1122
|
this.discoveredDevices = deviceList;
|
|
460
|
-
this.
|
|
1123
|
+
this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`);
|
|
461
1124
|
for (const d of deviceList) {
|
|
462
|
-
this.
|
|
1125
|
+
this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`);
|
|
1126
|
+
}
|
|
1127
|
+
const irDeviceList = Array.isArray(response?.body?.infraredRemoteList) ? response.body.infraredRemoteList : [];
|
|
1128
|
+
this.discoveredIRDevices = irDeviceList;
|
|
1129
|
+
this.infoLog(`Discovered ${irDeviceList.length} SwitchBot IR device(s) from OpenAPI`);
|
|
1130
|
+
for (const d of irDeviceList) {
|
|
1131
|
+
this.debugLog(` - ${d.deviceName} (${d.remoteType}) id=${d.deviceId}`);
|
|
463
1132
|
}
|
|
1133
|
+
// Diagnostic: warn users if their device count + refresh rate may exceed daily limits
|
|
1134
|
+
this.validateApiUsageConfig(deviceList.length, irDeviceList.length);
|
|
464
1135
|
}
|
|
465
1136
|
else {
|
|
466
|
-
this.
|
|
1137
|
+
this.warnLog(`SwitchBot getDevices returned status ${statusCode}`);
|
|
1138
|
+
// If rate limit exceeded (429), log specific message
|
|
1139
|
+
if (statusCode === 429) {
|
|
1140
|
+
this.warnLog('OpenAPI rate limit (429) exceeded during discovery.');
|
|
1141
|
+
this.warnLog('Webhook functionality will still work for manually configured devices.');
|
|
1142
|
+
this.warnLog('Device state updates will be limited until rate limit resets.');
|
|
1143
|
+
}
|
|
467
1144
|
}
|
|
468
1145
|
}
|
|
469
1146
|
catch (e) {
|
|
470
|
-
this.
|
|
1147
|
+
this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e);
|
|
471
1148
|
}
|
|
472
1149
|
}
|
|
473
1150
|
/**
|
|
474
|
-
*
|
|
1151
|
+
* Setup MQTT connection (if configured) and route incoming webhook messages
|
|
1152
|
+
* to registered webhook handlers. Mirrors behaviour in platform-hap.
|
|
475
1153
|
*/
|
|
476
|
-
async
|
|
477
|
-
|
|
478
|
-
while (retryCount < maxRetries) {
|
|
1154
|
+
async setupMqtt() {
|
|
1155
|
+
if (this.config.options?.mqttURL) {
|
|
479
1156
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
1157
|
+
const { connectAsync } = asyncmqtt;
|
|
1158
|
+
this.mqttClient = await connectAsync(this.config.options?.mqttURL, this.config.options.mqttOptions || {});
|
|
1159
|
+
this.debugLog('MQTT connection has been established successfully.');
|
|
1160
|
+
this.mqttClient.on('error', async (e) => {
|
|
1161
|
+
this.errorLog(`Failed to publish MQTT messages. ${e.message ?? e}`);
|
|
1162
|
+
});
|
|
1163
|
+
if (!this.config.options?.webhookURL) {
|
|
1164
|
+
// receive webhook events via MQTT
|
|
1165
|
+
this.infoLog(`Webhook is configured to be received through ${this.config.options.mqttURL}/homebridge-switchbot/webhook.`);
|
|
1166
|
+
this.mqttClient.subscribe('homebridge-switchbot/webhook/+');
|
|
1167
|
+
this.mqttClient.on('message', async (topic, message) => {
|
|
1168
|
+
try {
|
|
1169
|
+
this.debugLog(`Received Webhook via MQTT: ${topic}=${message}`);
|
|
1170
|
+
const context = JSON.parse(message.toString());
|
|
1171
|
+
this.webhookEventHandler[context.deviceMac]?.(context);
|
|
1172
|
+
}
|
|
1173
|
+
catch (e) {
|
|
1174
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
catch (e) {
|
|
1180
|
+
this.mqttClient = null;
|
|
1181
|
+
this.errorLog(`Failed to establish MQTT connection. ${e.message ?? e}`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Setup OpenAPI webhook (if webhookURL configured) and forward incoming
|
|
1187
|
+
* webhook events to MQTT (if configured) and local handlers.
|
|
1188
|
+
*/
|
|
1189
|
+
async setupwebhook() {
|
|
1190
|
+
if (this.config.options?.webhookURL) {
|
|
1191
|
+
const url = this.config.options?.webhookURL;
|
|
1192
|
+
try {
|
|
1193
|
+
this.switchBotAPI?.setupWebhook(url);
|
|
1194
|
+
this.infoLog(`Webhook configured for URL: ${url}`);
|
|
1195
|
+
// Listen for webhook events
|
|
1196
|
+
this.switchBotAPI?.on('webhookEvent', (body) => {
|
|
1197
|
+
try {
|
|
1198
|
+
this.infoLog(`Received webhook event for device: ${body.context.deviceMac}`);
|
|
1199
|
+
if (this.config.options?.mqttURL) {
|
|
1200
|
+
const mac = body.context.deviceMac?.toLowerCase().match(/[\s\S]{1,2}/g)?.join(':');
|
|
1201
|
+
const options = this.config.options?.mqttPubOptions || {};
|
|
1202
|
+
this.mqttClient?.publish(`homebridge-switchbot/webhook/${mac}`, `${JSON.stringify(body.context)}`, options);
|
|
1203
|
+
}
|
|
1204
|
+
this.webhookEventHandler[body.context.deviceMac]?.(body.context);
|
|
1205
|
+
}
|
|
1206
|
+
catch (e) {
|
|
1207
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
catch (e) {
|
|
1212
|
+
this.errorLog(`Failed to setup webhook. Error: ${e.message ?? e}`);
|
|
1213
|
+
}
|
|
1214
|
+
this.api.on('shutdown', async () => {
|
|
1215
|
+
try {
|
|
1216
|
+
this.switchBotAPI?.deleteWebhook(url);
|
|
1217
|
+
}
|
|
1218
|
+
catch (e) {
|
|
1219
|
+
this.errorLog(`Failed to delete webhook. Error: ${e.message ?? e}`);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Retry wrapper for control commands using SwitchBot OpenAPI
|
|
1226
|
+
*/
|
|
1227
|
+
async retryCommand(deviceObj, bodyChange, maxRetries = 1, delayBetweenRetries = 1000) {
|
|
1228
|
+
// Check API budget BEFORE attempting any retries
|
|
1229
|
+
if (!this.apiTracker?.trySpend('command')) {
|
|
1230
|
+
return { response: {}, statusCode: 429 };
|
|
1231
|
+
}
|
|
1232
|
+
let retryCount = 0;
|
|
1233
|
+
while (retryCount < maxRetries) {
|
|
1234
|
+
try {
|
|
1235
|
+
if (!this.switchBotAPI) {
|
|
1236
|
+
throw new Error('SwitchBot OpenAPI not initialized');
|
|
1237
|
+
}
|
|
483
1238
|
const { response, statusCode } = await this.switchBotAPI.controlDevice(deviceObj.deviceId, bodyChange.command, bodyChange.parameter, bodyChange.commandType, this.config.credentials?.token, this.config.credentials?.secret);
|
|
484
1239
|
return { response, statusCode };
|
|
485
1240
|
}
|
|
486
1241
|
catch (e) {
|
|
487
|
-
this.
|
|
1242
|
+
this.debugLog(`retryCommand error: ${e?.message ?? e}`);
|
|
488
1243
|
}
|
|
489
1244
|
retryCount++;
|
|
490
1245
|
await sleep(delayBetweenRetries);
|
|
@@ -523,6 +1278,14 @@ export class SwitchBotMatterPlatform {
|
|
|
523
1278
|
const parts = c.split(':').map(Number);
|
|
524
1279
|
[r, g, b] = parts;
|
|
525
1280
|
}
|
|
1281
|
+
else if (c.includes(',')) {
|
|
1282
|
+
const parts = c.split(',').map(s => Number(s.trim()));
|
|
1283
|
+
[r, g, b] = parts;
|
|
1284
|
+
}
|
|
1285
|
+
else if (c.includes(' ')) {
|
|
1286
|
+
const parts = c.split(' ').map(s => Number(s.trim()));
|
|
1287
|
+
[r, g, b] = parts;
|
|
1288
|
+
}
|
|
526
1289
|
else if (c.startsWith('#')) {
|
|
527
1290
|
const hex = c.replace('#', '');
|
|
528
1291
|
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
@@ -541,13 +1304,512 @@ export class SwitchBotMatterPlatform {
|
|
|
541
1304
|
if (battery !== undefined) {
|
|
542
1305
|
result.battery = Number(battery);
|
|
543
1306
|
}
|
|
1307
|
+
// VOC / TVOC (some air quality devices report total volatile organic compounds)
|
|
1308
|
+
const voc = sd.voc ?? sd.tvoc;
|
|
1309
|
+
if (voc !== undefined) {
|
|
1310
|
+
result.voc = Number(voc);
|
|
1311
|
+
}
|
|
1312
|
+
// PM10 (some devices report PM10 alongside PM2.5)
|
|
1313
|
+
const pm10 = sd.pm10;
|
|
1314
|
+
if (pm10 !== undefined) {
|
|
1315
|
+
result.pm10 = Number(pm10);
|
|
1316
|
+
}
|
|
1317
|
+
// PM2.5 (some BLE adverts use pm25 / pm_2_5)
|
|
1318
|
+
const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5;
|
|
1319
|
+
if (pm25 !== undefined) {
|
|
1320
|
+
result.pm25 = Number(pm25);
|
|
1321
|
+
}
|
|
1322
|
+
// CO2 (carbon dioxide ppm)
|
|
1323
|
+
const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide;
|
|
1324
|
+
if (co2 !== undefined) {
|
|
1325
|
+
result.co2 = Number(co2);
|
|
1326
|
+
}
|
|
1327
|
+
// Temperature (C) and Humidity (%) — support common shorthand keys
|
|
1328
|
+
const temperature = sd.temperature ?? sd.temp ?? sd.t;
|
|
1329
|
+
if (temperature !== undefined) {
|
|
1330
|
+
result.temperature = Number(temperature);
|
|
1331
|
+
}
|
|
1332
|
+
const humidity = sd.humidity ?? sd.h ?? sd.humid;
|
|
1333
|
+
if (humidity !== undefined) {
|
|
1334
|
+
result.humidity = Number(humidity);
|
|
1335
|
+
}
|
|
1336
|
+
// Motion, Contact, Leak
|
|
1337
|
+
const motion = sd.motion ?? sd.m;
|
|
1338
|
+
if (motion !== undefined) {
|
|
1339
|
+
result.motion = Boolean(motion);
|
|
1340
|
+
}
|
|
1341
|
+
const contact = sd.contact ?? sd.open;
|
|
1342
|
+
if (contact !== undefined) {
|
|
1343
|
+
result.contact = contact;
|
|
1344
|
+
}
|
|
1345
|
+
const leak = sd.leak ?? sd.water;
|
|
1346
|
+
if (leak !== undefined) {
|
|
1347
|
+
result.leak = Boolean(leak);
|
|
1348
|
+
}
|
|
1349
|
+
// Position / Cover / Curtain synonyms
|
|
1350
|
+
const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition;
|
|
1351
|
+
if (position !== undefined) {
|
|
1352
|
+
result.position = Number(position);
|
|
1353
|
+
}
|
|
1354
|
+
// Fan speed/speed
|
|
1355
|
+
const fanSpeed = sd.fanSpeed ?? sd.speed;
|
|
1356
|
+
if (fanSpeed !== undefined) {
|
|
1357
|
+
result.fanSpeed = Number(fanSpeed);
|
|
1358
|
+
}
|
|
1359
|
+
// Lock state
|
|
1360
|
+
const lock = sd.lock;
|
|
1361
|
+
if (lock !== undefined) {
|
|
1362
|
+
result.lock = lock;
|
|
1363
|
+
}
|
|
1364
|
+
// Robot vacuum fields
|
|
1365
|
+
const rvcRunMode = sd.rvcRunMode;
|
|
1366
|
+
if (rvcRunMode !== undefined) {
|
|
1367
|
+
result.rvcRunMode = rvcRunMode;
|
|
1368
|
+
}
|
|
1369
|
+
const rvcOperationalState = sd.rvcOperationalState;
|
|
1370
|
+
if (rvcOperationalState !== undefined) {
|
|
1371
|
+
result.rvcOperationalState = rvcOperationalState;
|
|
1372
|
+
}
|
|
544
1373
|
return result;
|
|
545
1374
|
}
|
|
546
1375
|
catch (e) {
|
|
547
|
-
this.
|
|
1376
|
+
this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
548
1377
|
return null;
|
|
549
1378
|
}
|
|
550
1379
|
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
1382
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
1383
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
1384
|
+
*/
|
|
1385
|
+
async applyStatusToAccessory(uuidLocal, dev, status) {
|
|
1386
|
+
if (!status) {
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined;
|
|
1390
|
+
// Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
|
|
1391
|
+
const safeUpdate = async (cluster, attributes, methodName) => {
|
|
1392
|
+
try {
|
|
1393
|
+
// Special-case: powerSource cluster is optional on many devices (e.g., Curtains/Blinds).
|
|
1394
|
+
// To avoid noisy Matter server errors ("Behavior ID powerSource does not exist"),
|
|
1395
|
+
// always use the direct updateAccessoryState path wrapped in a guard for this cluster,
|
|
1396
|
+
// even when an accessory instance is present.
|
|
1397
|
+
const powerClusterName = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource)
|
|
1398
|
+
? this.api.matter.clusterNames.PowerSource
|
|
1399
|
+
: 'powerSource';
|
|
1400
|
+
const isPowerSourceCluster = cluster === powerClusterName || cluster === 'powerSource';
|
|
1401
|
+
// If the accessory instance declares supported clusters, skip updates for clusters
|
|
1402
|
+
// not present to avoid triggering Matter server errors and logs.
|
|
1403
|
+
let clusterSupported = true;
|
|
1404
|
+
if (instance && instance.clusters) {
|
|
1405
|
+
const declared = instance.clusters;
|
|
1406
|
+
if (Array.isArray(declared)) {
|
|
1407
|
+
clusterSupported = declared.includes(cluster);
|
|
1408
|
+
}
|
|
1409
|
+
else if (typeof declared === 'object') {
|
|
1410
|
+
clusterSupported = Object.prototype.hasOwnProperty.call(declared, cluster);
|
|
1411
|
+
}
|
|
1412
|
+
if (!clusterSupported) {
|
|
1413
|
+
this.debugLog(`Cluster ${cluster} not declared on accessory for ${dev.deviceId}, skipping update`);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (instance && methodName && typeof instance[methodName] === 'function') {
|
|
1418
|
+
// prefer device-specific update helpers when available
|
|
1419
|
+
await instance[methodName](...(Object.values(attributes)));
|
|
1420
|
+
}
|
|
1421
|
+
else if (!isPowerSourceCluster && instance && typeof instance.updateState === 'function') {
|
|
1422
|
+
// some accessories expose updateState that accepts cluster and attributes
|
|
1423
|
+
await instance.updateState(cluster, attributes);
|
|
1424
|
+
}
|
|
1425
|
+
else {
|
|
1426
|
+
try {
|
|
1427
|
+
await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes);
|
|
1428
|
+
}
|
|
1429
|
+
catch (updateError) {
|
|
1430
|
+
// Silently ignore "does not exist" errors for clusters that aren't
|
|
1431
|
+
// supported by this device type (e.g., powerSource on WindowBlind).
|
|
1432
|
+
const msg = String(updateError?.message ?? updateError);
|
|
1433
|
+
if (msg.includes('does not exist') || msg.includes('not found')) {
|
|
1434
|
+
this.debugLog(`Cluster ${cluster} not available on ${dev.deviceId}, skipping update`);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
throw updateError;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch (e) {
|
|
1442
|
+
this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`);
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
try {
|
|
1446
|
+
// On/Off
|
|
1447
|
+
if (status?.power !== undefined) {
|
|
1448
|
+
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true);
|
|
1449
|
+
await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState');
|
|
1450
|
+
}
|
|
1451
|
+
// Robot vacuum: some OpenAPI responses use 'runState' or similar textual
|
|
1452
|
+
// fields to indicate cleaning/mapping/idle. Map common textual values to
|
|
1453
|
+
// the numeric run mode values expected by the RoboticVacuumAccessory and
|
|
1454
|
+
// prefer calling accessory helpers when present.
|
|
1455
|
+
if (status?.runState !== undefined || status?.run_state !== undefined || status?.run !== undefined) {
|
|
1456
|
+
try {
|
|
1457
|
+
const raw = status?.runState ?? status?.run_state ?? status?.run;
|
|
1458
|
+
let mode;
|
|
1459
|
+
if (typeof raw === 'number') {
|
|
1460
|
+
mode = Number(raw);
|
|
1461
|
+
}
|
|
1462
|
+
else if (typeof raw === 'string') {
|
|
1463
|
+
const s = raw.toLowerCase();
|
|
1464
|
+
if (s.includes('clean')) {
|
|
1465
|
+
mode = 1; // Cleaning
|
|
1466
|
+
}
|
|
1467
|
+
else if (s.includes('map')) {
|
|
1468
|
+
mode = 2; // Mapping
|
|
1469
|
+
}
|
|
1470
|
+
else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1471
|
+
mode = 0; // Idle
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
const n = Number(raw);
|
|
1475
|
+
if (!Number.isNaN(n)) {
|
|
1476
|
+
mode = n;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (mode !== undefined) {
|
|
1481
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode');
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
catch (e) {
|
|
1485
|
+
this.debugLog(`Failed to apply runState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// Robot vacuum: some firmwares expose 'taskType' or 'task' with similar semantics
|
|
1489
|
+
// to runState (e.g., 'cleaning', 'mapping', 'idle'). Map to rvcRunMode accordingly.
|
|
1490
|
+
if (status?.taskType !== undefined || status?.task_type !== undefined || status?.task !== undefined) {
|
|
1491
|
+
try {
|
|
1492
|
+
const raw = status?.taskType ?? status?.task_type ?? status?.task;
|
|
1493
|
+
let mode;
|
|
1494
|
+
if (typeof raw === 'number') {
|
|
1495
|
+
mode = Number(raw);
|
|
1496
|
+
}
|
|
1497
|
+
else if (typeof raw === 'string') {
|
|
1498
|
+
const s = raw.toLowerCase();
|
|
1499
|
+
if (s.includes('clean')) {
|
|
1500
|
+
mode = 1; // Cleaning
|
|
1501
|
+
}
|
|
1502
|
+
else if (s.includes('map')) {
|
|
1503
|
+
mode = 2; // Mapping
|
|
1504
|
+
}
|
|
1505
|
+
else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1506
|
+
mode = 0; // Idle
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
const n = Number(raw);
|
|
1510
|
+
if (!Number.isNaN(n)) {
|
|
1511
|
+
mode = n;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
if (mode !== undefined) {
|
|
1516
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode');
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
catch (e) {
|
|
1520
|
+
this.debugLog(`Failed to apply taskType for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// Brightness
|
|
1524
|
+
if (status?.brightness !== undefined) {
|
|
1525
|
+
const rawBrightness = Number(status.brightness);
|
|
1526
|
+
const clampedBrightness = Math.max(0, Math.min(100, rawBrightness));
|
|
1527
|
+
// If instance has updateBrightness method, it expects percentage (0-100)
|
|
1528
|
+
// Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
|
|
1529
|
+
if (instance && typeof instance.updateBrightness === 'function') {
|
|
1530
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`);
|
|
1531
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness');
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
const level = Math.round((clampedBrightness / 100) * 254);
|
|
1535
|
+
const clampedLevel = Math.max(0, Math.min(254, level));
|
|
1536
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`);
|
|
1537
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel });
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// Color
|
|
1541
|
+
if (status?.color !== undefined) {
|
|
1542
|
+
const color = String(status.color);
|
|
1543
|
+
let r = 0;
|
|
1544
|
+
let g = 0;
|
|
1545
|
+
let b = 0;
|
|
1546
|
+
if (color.includes(':')) {
|
|
1547
|
+
const parts = color.split(':').map(Number);
|
|
1548
|
+
[r, g, b] = parts;
|
|
1549
|
+
}
|
|
1550
|
+
else if (color.includes(',')) {
|
|
1551
|
+
const parts = color.split(',').map(s => Number(s.trim()));
|
|
1552
|
+
[r, g, b] = parts;
|
|
1553
|
+
}
|
|
1554
|
+
else if (color.includes(' ')) {
|
|
1555
|
+
const parts = color.split(' ').map(s => Number(s.trim()));
|
|
1556
|
+
[r, g, b] = parts;
|
|
1557
|
+
}
|
|
1558
|
+
else if (color.startsWith('#')) {
|
|
1559
|
+
const hex = color.replace('#', '');
|
|
1560
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
1561
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
1562
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
1563
|
+
}
|
|
1564
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
1565
|
+
const clampedH = Math.max(0, Math.min(360, h));
|
|
1566
|
+
const clampedS = Math.max(0, Math.min(100, s));
|
|
1567
|
+
// If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
|
|
1568
|
+
// Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
|
|
1569
|
+
if (instance && typeof instance.updateHueSaturation === 'function') {
|
|
1570
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`);
|
|
1571
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation');
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
const hue = Math.round((clampedH / 360) * 254);
|
|
1575
|
+
const sat = Math.round((clampedS / 100) * 254);
|
|
1576
|
+
const clampedHue = Math.max(0, Math.min(254, hue));
|
|
1577
|
+
const clampedSat = Math.max(0, Math.min(254, sat));
|
|
1578
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`);
|
|
1579
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat });
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
// Battery/powerSource (support many possible field names)
|
|
1583
|
+
// Note: Some device types like WindowBlind and RoboticVacuumCleaner don't support PowerSource cluster
|
|
1584
|
+
if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined
|
|
1585
|
+
|| status?.baseBattery !== undefined || status?.base_battery !== undefined || status?.stationBattery !== undefined || status?.waterBaseBattery !== undefined || status?.dockBattery !== undefined) {
|
|
1586
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
1587
|
+
const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '');
|
|
1588
|
+
const unsupportedTypes = [
|
|
1589
|
+
'Curtain',
|
|
1590
|
+
'Curtain2',
|
|
1591
|
+
'Curtain3',
|
|
1592
|
+
'Curtain 2',
|
|
1593
|
+
'Blind Tilt',
|
|
1594
|
+
// Robot Vacuums - PowerSource cluster not in Matter spec for RoboticVacuumCleaner
|
|
1595
|
+
'K10+',
|
|
1596
|
+
'K10+ Pro',
|
|
1597
|
+
'WoSweeper',
|
|
1598
|
+
'WoSweeperMini',
|
|
1599
|
+
'Robot Vacuum Cleaner S1',
|
|
1600
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1601
|
+
'Robot Vacuum Cleaner S10',
|
|
1602
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1603
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1604
|
+
];
|
|
1605
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
1606
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`);
|
|
1607
|
+
}
|
|
1608
|
+
else {
|
|
1609
|
+
try {
|
|
1610
|
+
const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level
|
|
1611
|
+
?? status?.baseBattery ?? status?.base_battery ?? status?.stationBattery ?? status?.waterBaseBattery ?? status?.dockBattery);
|
|
1612
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
1613
|
+
let batChargeLevel = 0;
|
|
1614
|
+
if (percentage < 20) {
|
|
1615
|
+
batChargeLevel = 2;
|
|
1616
|
+
}
|
|
1617
|
+
else if (percentage < 40) {
|
|
1618
|
+
batChargeLevel = 1;
|
|
1619
|
+
}
|
|
1620
|
+
const powerCluster = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource) ? this.api.matter.clusterNames.PowerSource : 'powerSource';
|
|
1621
|
+
await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage');
|
|
1622
|
+
}
|
|
1623
|
+
catch (e) {
|
|
1624
|
+
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
// Temperature + thermostat
|
|
1629
|
+
if (status?.temperature !== undefined || status?.temp !== undefined) {
|
|
1630
|
+
try {
|
|
1631
|
+
const c = Number(status?.temperature ?? status?.temp);
|
|
1632
|
+
const measured = Math.round(c * 100);
|
|
1633
|
+
await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature');
|
|
1634
|
+
// Thermostat-specific mapping
|
|
1635
|
+
if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
|
|
1636
|
+
const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint);
|
|
1637
|
+
const val = Math.round(target * 100);
|
|
1638
|
+
await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint');
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
catch (e) {
|
|
1642
|
+
this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
// Humidity (support different keys)
|
|
1646
|
+
if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
|
|
1647
|
+
try {
|
|
1648
|
+
const percent = Number(status?.humidity ?? status?.h ?? status?.humid);
|
|
1649
|
+
const measured = Math.round(percent * 100);
|
|
1650
|
+
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity');
|
|
1651
|
+
}
|
|
1652
|
+
catch (e) {
|
|
1653
|
+
this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
// Contact / Leak -> BooleanState
|
|
1657
|
+
if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
|
|
1658
|
+
try {
|
|
1659
|
+
const isContactOpen = status?.contact ?? status?.open;
|
|
1660
|
+
if (isContactOpen !== undefined) {
|
|
1661
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
1662
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1663
|
+
}
|
|
1664
|
+
else {
|
|
1665
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
const leakDetected = status?.leak ?? status?.water;
|
|
1669
|
+
if (leakDetected !== undefined) {
|
|
1670
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState');
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
catch (e) {
|
|
1674
|
+
this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// Motion -> occupancy
|
|
1678
|
+
if (status?.motion !== undefined || status?.m !== undefined) {
|
|
1679
|
+
try {
|
|
1680
|
+
const detected = Boolean(status?.motion ?? status?.m);
|
|
1681
|
+
await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy');
|
|
1682
|
+
}
|
|
1683
|
+
catch (e) {
|
|
1684
|
+
this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
// Lock state
|
|
1688
|
+
if (status?.lock !== undefined) {
|
|
1689
|
+
try {
|
|
1690
|
+
const s = String(status.lock).toLowerCase();
|
|
1691
|
+
let lockState = 0;
|
|
1692
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
1693
|
+
lockState = 1;
|
|
1694
|
+
}
|
|
1695
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
1696
|
+
lockState = 2;
|
|
1697
|
+
}
|
|
1698
|
+
await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState');
|
|
1699
|
+
}
|
|
1700
|
+
catch (e) {
|
|
1701
|
+
this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
// Cover position
|
|
1705
|
+
if (status?.position !== undefined || status?.percent !== undefined) {
|
|
1706
|
+
try {
|
|
1707
|
+
const openPercent = Number(status?.position ?? status?.percent);
|
|
1708
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
1709
|
+
const value = Math.round(closedPercent * 100);
|
|
1710
|
+
await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition');
|
|
1711
|
+
}
|
|
1712
|
+
catch (e) {
|
|
1713
|
+
this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Fan
|
|
1717
|
+
if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
|
|
1718
|
+
try {
|
|
1719
|
+
const percent = Number(status?.fanSpeed ?? status?.speed);
|
|
1720
|
+
await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed');
|
|
1721
|
+
}
|
|
1722
|
+
catch (e) {
|
|
1723
|
+
this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
// Robot vacuum: run/operational/clean modes
|
|
1727
|
+
if (status?.rvcRunMode !== undefined) {
|
|
1728
|
+
try {
|
|
1729
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode');
|
|
1730
|
+
}
|
|
1731
|
+
catch (e) {
|
|
1732
|
+
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// CO2 (carbon dioxide) - support common synonyms
|
|
1736
|
+
if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
|
|
1737
|
+
try {
|
|
1738
|
+
const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide);
|
|
1739
|
+
await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2');
|
|
1740
|
+
}
|
|
1741
|
+
catch (e) {
|
|
1742
|
+
this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
// PM2.5 / particulate matter
|
|
1746
|
+
if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
|
|
1747
|
+
try {
|
|
1748
|
+
const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5);
|
|
1749
|
+
await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25');
|
|
1750
|
+
}
|
|
1751
|
+
catch (e) {
|
|
1752
|
+
this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
// PM10 (some devices report pm10)
|
|
1756
|
+
if (status?.pm10 !== undefined) {
|
|
1757
|
+
try {
|
|
1758
|
+
const pm10 = Number(status?.pm10);
|
|
1759
|
+
await safeUpdate('pm10', { pm10 }, 'updatePM10');
|
|
1760
|
+
}
|
|
1761
|
+
catch (e) {
|
|
1762
|
+
this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
// VOC / TVOC - volatile organic compounds
|
|
1766
|
+
if (status?.voc !== undefined || status?.tvoc !== undefined) {
|
|
1767
|
+
try {
|
|
1768
|
+
const val = Number(status?.voc ?? status?.tvoc);
|
|
1769
|
+
await safeUpdate('voc', { voc: val }, 'updateVOC');
|
|
1770
|
+
}
|
|
1771
|
+
catch (e) {
|
|
1772
|
+
this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (status?.rvcOperationalState !== undefined) {
|
|
1776
|
+
try {
|
|
1777
|
+
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState');
|
|
1778
|
+
}
|
|
1779
|
+
catch (e) {
|
|
1780
|
+
this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
catch (e) {
|
|
1785
|
+
this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Apply status update with a small retry if the accessory is not yet registered.
|
|
1790
|
+
* Useful for first-time updates that can race with registration.
|
|
1791
|
+
*/
|
|
1792
|
+
async applyStatusWithRegistrationRetry(uuidLocal, dev, status, retries = 1, delayMs = 300) {
|
|
1793
|
+
let attempt = 0;
|
|
1794
|
+
// Normalize retries bounds
|
|
1795
|
+
const maxAttempts = Math.max(1, retries + 1);
|
|
1796
|
+
while (attempt < maxAttempts) {
|
|
1797
|
+
try {
|
|
1798
|
+
await this.applyStatusToAccessory(uuidLocal, dev, status);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
catch (e) {
|
|
1802
|
+
const msg = String(e?.message ?? e);
|
|
1803
|
+
const notReady = msg.includes('not found') || msg.includes('not registered');
|
|
1804
|
+
if (!notReady || attempt >= maxAttempts - 1) {
|
|
1805
|
+
throw e;
|
|
1806
|
+
}
|
|
1807
|
+
this.debugLog(`Accessory ${uuidLocal} not registered yet for ${dev.deviceId}, retrying in ${delayMs}ms`);
|
|
1808
|
+
await sleep(delayMs);
|
|
1809
|
+
attempt++;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
551
1813
|
/**
|
|
552
1814
|
* Required for DynamicPlatformPlugin
|
|
553
1815
|
* Called when homebridge restores cached accessories from disk at startup
|
|
@@ -564,55 +1826,223 @@ export class SwitchBotMatterPlatform {
|
|
|
564
1826
|
* any custom data you stored when the accessory was originally registered.
|
|
565
1827
|
*/
|
|
566
1828
|
configureMatterAccessory(accessory) {
|
|
567
|
-
this.
|
|
1829
|
+
this.debugLog('Loading cached Matter accessory:', accessory.displayName);
|
|
568
1830
|
this.matterAccessories.set(accessory.uuid, accessory);
|
|
569
1831
|
}
|
|
570
1832
|
/**
|
|
571
1833
|
* Register all Matter accessories
|
|
572
1834
|
*/
|
|
573
1835
|
async registerMatterAccessories() {
|
|
574
|
-
this.log.info('═'.repeat(80));
|
|
575
|
-
this.log.info('Homebridge Matter Plugin');
|
|
576
|
-
this.log.info('═'.repeat(80));
|
|
577
1836
|
// Remove accessories that are disabled in config
|
|
578
1837
|
await this.removeDisabledAccessories();
|
|
579
1838
|
// If we discovered real SwitchBot devices via OpenAPI, map and register them
|
|
580
1839
|
if (this.discoveredDevices && this.discoveredDevices.length > 0) {
|
|
581
|
-
this.
|
|
582
|
-
|
|
583
|
-
|
|
1840
|
+
this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`);
|
|
1841
|
+
// Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
|
|
1842
|
+
const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices);
|
|
1843
|
+
// By default, automatically remove previously-registered Matter
|
|
1844
|
+
// accessories whose deviceId is not present in the merged discovered
|
|
1845
|
+
// list. If the user explicitly sets `options.keepStaleAccessories` to
|
|
1846
|
+
// true, then we will keep previously-registered accessories (legacy
|
|
1847
|
+
// behavior).
|
|
1848
|
+
if (this.config.options?.keepStaleAccessories) {
|
|
1849
|
+
this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true');
|
|
1850
|
+
}
|
|
1851
|
+
else {
|
|
1852
|
+
try {
|
|
1853
|
+
const desiredIds = new Set((devicesToProcess || []).map((d) => this.normalizeDeviceId(d.deviceId)));
|
|
1854
|
+
const toUnregister = [];
|
|
1855
|
+
for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
|
|
1856
|
+
try {
|
|
1857
|
+
const deviceId = acc?.context?.deviceId;
|
|
1858
|
+
if (!deviceId) {
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
1862
|
+
if (!desiredIds.has(nid)) {
|
|
1863
|
+
// Accessory exists but is no longer desired -> schedule for removal
|
|
1864
|
+
this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`);
|
|
1865
|
+
try {
|
|
1866
|
+
this.clearDeviceResources(deviceId);
|
|
1867
|
+
}
|
|
1868
|
+
catch (e) {
|
|
1869
|
+
this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`);
|
|
1870
|
+
}
|
|
1871
|
+
toUnregister.push(acc);
|
|
1872
|
+
this.matterAccessories.delete(uuid);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
catch (e) {
|
|
1876
|
+
this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
if (toUnregister.length > 0) {
|
|
1880
|
+
try {
|
|
1881
|
+
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister);
|
|
1882
|
+
}
|
|
1883
|
+
catch (e) {
|
|
1884
|
+
this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
catch (e) {
|
|
1889
|
+
this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
// We'll separate discovered devices into two buckets:
|
|
1893
|
+
// - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
|
|
1894
|
+
// - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
|
|
1895
|
+
const platformAccessories = [];
|
|
1896
|
+
const roboticAccessories = [];
|
|
1897
|
+
// Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
|
|
1898
|
+
const robotTypes = new Set([
|
|
1899
|
+
'K10+',
|
|
1900
|
+
'K10+ Pro',
|
|
1901
|
+
'WoSweeper',
|
|
1902
|
+
'WoSweeperMini',
|
|
1903
|
+
'Robot Vacuum Cleaner S1',
|
|
1904
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1905
|
+
'Robot Vacuum Cleaner S10',
|
|
1906
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1907
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1908
|
+
]);
|
|
1909
|
+
for (const dev of devicesToProcess) {
|
|
584
1910
|
try {
|
|
585
1911
|
const acc = await this.createAccessoryFromDevice(dev);
|
|
586
|
-
if (acc) {
|
|
587
|
-
|
|
1912
|
+
if (!acc) {
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
if (robotTypes.has(dev.deviceType ?? '')) {
|
|
1916
|
+
roboticAccessories.push(acc);
|
|
1917
|
+
}
|
|
1918
|
+
else {
|
|
1919
|
+
platformAccessories.push(acc);
|
|
588
1920
|
}
|
|
589
1921
|
}
|
|
590
1922
|
catch (e) {
|
|
591
|
-
this.
|
|
1923
|
+
this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
592
1924
|
}
|
|
593
1925
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1926
|
+
// Register platform-hosted accessories (most devices)
|
|
1927
|
+
if (platformAccessories.length > 0) {
|
|
1928
|
+
this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`);
|
|
1929
|
+
for (const acc of platformAccessories) {
|
|
1930
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
598
1931
|
}
|
|
599
|
-
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME,
|
|
600
|
-
|
|
1932
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories);
|
|
1933
|
+
}
|
|
1934
|
+
// Register robotic accessories (robot vacuums) separately so they can be
|
|
1935
|
+
// commissioned in the way Apple Home expects (these devices often require
|
|
1936
|
+
// standalone commissioning flow). We still call registerPlatformAccessories
|
|
1937
|
+
// because the accessory implementations manage their commissioning behavior.
|
|
1938
|
+
if (roboticAccessories.length > 0) {
|
|
1939
|
+
this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`);
|
|
1940
|
+
for (const acc of roboticAccessories) {
|
|
1941
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
1942
|
+
}
|
|
1943
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories);
|
|
601
1944
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1945
|
+
// Debug/info: how many discovered vs example accessories were registered.
|
|
1946
|
+
// Example accessories are disabled — we intentionally do NOT register them.
|
|
1947
|
+
const discoveredRegistered = platformAccessories.length + roboticAccessories.length;
|
|
1948
|
+
const exampleRegistered = 0;
|
|
1949
|
+
this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`);
|
|
1950
|
+
// Dump registry state to help runtime debugging: which accessory instances
|
|
1951
|
+
// were created and which refresh timers are scheduled. This helps confirm
|
|
1952
|
+
// whether safeUpdate will prefer accessory helpers and whether periodic
|
|
1953
|
+
// refreshes exist for each device.
|
|
1954
|
+
try {
|
|
1955
|
+
const instanceKeys = Array.from(this.accessoryInstances.keys());
|
|
1956
|
+
this.debugLog(`Accessory instances registered (${instanceKeys.length}): ${JSON.stringify(instanceKeys)}`);
|
|
1957
|
+
const timerKeys = Array.from(this.refreshTimers.keys());
|
|
1958
|
+
this.debugLog(`Refresh timers scheduled (${timerKeys.length}): ${JSON.stringify(timerKeys)}`);
|
|
1959
|
+
}
|
|
1960
|
+
catch (e) {
|
|
1961
|
+
this.debugLog(`Failed to dump platform registries: ${e?.message ?? e}`);
|
|
1962
|
+
}
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
// If no discovered devices are available, check for cached Matter accessories
|
|
1966
|
+
const hasCachedAccessories = this.matterAccessories.size > 0;
|
|
1967
|
+
if (hasCachedAccessories) {
|
|
1968
|
+
this.infoLog(`No devices discovered via OpenAPI, but found ${this.matterAccessories.size} cached Matter accessories.`);
|
|
1969
|
+
this.infoLog('Cached accessories will continue to function with webhook updates.');
|
|
1970
|
+
this.infoLog('Restoring webhook handlers for cached Matter accessories...');
|
|
1971
|
+
await this.restoreCachedMatterAccessoryWebhooks();
|
|
1972
|
+
this.infoLog('Device discovery will resume when API becomes available.');
|
|
1973
|
+
}
|
|
1974
|
+
else {
|
|
1975
|
+
this.infoLog('No discovered SwitchBot devices found.');
|
|
1976
|
+
}
|
|
1977
|
+
this.debugLog('═'.repeat(80));
|
|
1978
|
+
this.debugLog('Finished registering Matter accessories');
|
|
1979
|
+
this.debugLog('═'.repeat(80));
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Restore webhook handlers for cached Matter accessories
|
|
1983
|
+
* This ensures webhook functionality continues to work even when device discovery fails
|
|
1984
|
+
*/
|
|
1985
|
+
async restoreCachedMatterAccessoryWebhooks() {
|
|
1986
|
+
this.debugLog('Restoring webhook handlers for cached Matter accessories...');
|
|
1987
|
+
let restoredCount = 0;
|
|
1988
|
+
let fallbackCount = 0;
|
|
1989
|
+
for (const [uuid, accessory] of this.matterAccessories.entries()) {
|
|
1990
|
+
try {
|
|
1991
|
+
const context = accessory?.context;
|
|
1992
|
+
const deviceId = context?.deviceId;
|
|
1993
|
+
let webhook = context?.webhook;
|
|
1994
|
+
// If cached accessory predates global webhook context, fall back to global option
|
|
1995
|
+
if (webhook === undefined && this.config.options?.webhook === true) {
|
|
1996
|
+
webhook = true;
|
|
1997
|
+
try {
|
|
1998
|
+
;
|
|
1999
|
+
accessory.context.webhook = true;
|
|
2000
|
+
this.debugLog(`Applying global webhook fallback for cached Matter device ${deviceId}`);
|
|
2001
|
+
fallbackCount++;
|
|
2002
|
+
}
|
|
2003
|
+
catch (e) {
|
|
2004
|
+
this.debugLog(`Failed to persist global webhook fallback for ${deviceId}: ${e?.message ?? e}`);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
if (!deviceId) {
|
|
2008
|
+
this.debugLog(`Skipping cached accessory ${accessory.displayName} - no deviceId in context`);
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
// Only register webhook if the device had webhook enabled
|
|
2012
|
+
if (webhook) {
|
|
2013
|
+
this.debugLog(`Restoring webhook handler for Matter device: ${deviceId}`);
|
|
2014
|
+
// Create a minimal device object from cached context for webhook handling
|
|
2015
|
+
const dev = {
|
|
2016
|
+
deviceId,
|
|
2017
|
+
deviceName: context?.name || accessory.displayName,
|
|
2018
|
+
deviceType: context?.deviceType,
|
|
2019
|
+
webhook: true,
|
|
2020
|
+
};
|
|
2021
|
+
this.webhookEventHandler[deviceId] = async (webhookContext) => {
|
|
2022
|
+
try {
|
|
2023
|
+
this.debugLog(`Received webhook for cached Matter device ${deviceId}: ${JSON.stringify(webhookContext)}`);
|
|
2024
|
+
// Apply webhook status update to the accessory
|
|
2025
|
+
await this.applyStatusWithRegistrationRetry(uuid, dev, webhookContext);
|
|
2026
|
+
}
|
|
2027
|
+
catch (e) {
|
|
2028
|
+
this.errorLog(`Failed to handle webhook for cached device ${deviceId}: ${e?.message ?? e}`);
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
2031
|
+
restoredCount++;
|
|
2032
|
+
this.debugSuccessLog(`Webhook handler restored for ${deviceId}`);
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
this.debugLog(`Device ${deviceId} does not have webhook enabled, skipping`);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
catch (e) {
|
|
2039
|
+
this.errorLog(`Failed to restore webhook handler for cached accessory ${accessory.displayName}: ${e?.message ?? e}`);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
if (fallbackCount > 0) {
|
|
2043
|
+
this.infoLog(`Applied global webhook fallback for ${fallbackCount} cached Matter accessories`);
|
|
2044
|
+
}
|
|
2045
|
+
this.infoLog(`Restored webhook handlers for ${restoredCount} cached Matter accessories`);
|
|
616
2046
|
}
|
|
617
2047
|
/**
|
|
618
2048
|
* Remove accessories that are disabled in config
|
|
@@ -645,7 +2075,17 @@ export class SwitchBotMatterPlatform {
|
|
|
645
2075
|
if (enabled === false) {
|
|
646
2076
|
const existingAccessory = this.matterAccessories.get(uuid);
|
|
647
2077
|
if (existingAccessory) {
|
|
648
|
-
this.
|
|
2078
|
+
this.infoLog(`Removing accessory '${name}' (disabled in config)`);
|
|
2079
|
+
// Attempt to clear any per-device resources (timers, BLE handlers, instances)
|
|
2080
|
+
try {
|
|
2081
|
+
const deviceId = existingAccessory?.context?.deviceId;
|
|
2082
|
+
if (deviceId) {
|
|
2083
|
+
this.clearDeviceResources(deviceId);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
catch (e) {
|
|
2087
|
+
this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`);
|
|
2088
|
+
}
|
|
649
2089
|
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
|
|
650
2090
|
this.matterAccessories.delete(uuid);
|
|
651
2091
|
}
|
|
@@ -656,9 +2096,9 @@ export class SwitchBotMatterPlatform {
|
|
|
656
2096
|
* Section 4: Lighting Devices (Matter Spec § 4)
|
|
657
2097
|
*/
|
|
658
2098
|
async registerSection4Lighting() {
|
|
659
|
-
this.
|
|
660
|
-
this.
|
|
661
|
-
this.
|
|
2099
|
+
this.debugLog('═'.repeat(80));
|
|
2100
|
+
this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)');
|
|
2101
|
+
this.debugLog('═'.repeat(80));
|
|
662
2102
|
const accessories = [];
|
|
663
2103
|
// On/Off Light
|
|
664
2104
|
if (this.config.enableOnOffLight !== false) {
|
|
@@ -686,9 +2126,9 @@ export class SwitchBotMatterPlatform {
|
|
|
686
2126
|
accessories.push(device.toAccessory());
|
|
687
2127
|
}
|
|
688
2128
|
if (accessories.length > 0) {
|
|
689
|
-
this.
|
|
2129
|
+
this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`);
|
|
690
2130
|
for (const acc of accessories) {
|
|
691
|
-
this.
|
|
2131
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
692
2132
|
}
|
|
693
2133
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
694
2134
|
}
|
|
@@ -697,9 +2137,9 @@ export class SwitchBotMatterPlatform {
|
|
|
697
2137
|
* Section 5: Smart Plugs/Actuators (Matter Spec § 5)
|
|
698
2138
|
*/
|
|
699
2139
|
async registerSection5SmartPlugs() {
|
|
700
|
-
this.
|
|
701
|
-
this.
|
|
702
|
-
this.
|
|
2140
|
+
this.debugLog('═'.repeat(80));
|
|
2141
|
+
this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)');
|
|
2142
|
+
this.debugLog('═'.repeat(80));
|
|
703
2143
|
const accessories = [];
|
|
704
2144
|
// On/Off Outlet
|
|
705
2145
|
if (this.config.enableOnOffOutlet !== false) {
|
|
@@ -707,9 +2147,9 @@ export class SwitchBotMatterPlatform {
|
|
|
707
2147
|
accessories.push(device.toAccessory());
|
|
708
2148
|
}
|
|
709
2149
|
if (accessories.length > 0) {
|
|
710
|
-
this.
|
|
2150
|
+
this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`);
|
|
711
2151
|
for (const acc of accessories) {
|
|
712
|
-
this.
|
|
2152
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
713
2153
|
}
|
|
714
2154
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
715
2155
|
}
|
|
@@ -718,9 +2158,9 @@ export class SwitchBotMatterPlatform {
|
|
|
718
2158
|
* Section 6: Switches & Controllers (Matter Spec § 6)
|
|
719
2159
|
*/
|
|
720
2160
|
async registerSection6Switches() {
|
|
721
|
-
this.
|
|
722
|
-
this.
|
|
723
|
-
this.
|
|
2161
|
+
this.debugLog('═'.repeat(80));
|
|
2162
|
+
this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)');
|
|
2163
|
+
this.debugLog('═'.repeat(80));
|
|
724
2164
|
const accessories = [];
|
|
725
2165
|
// On/Off Switch
|
|
726
2166
|
if (this.config.enableOnOffSwitch !== false) {
|
|
@@ -728,9 +2168,9 @@ export class SwitchBotMatterPlatform {
|
|
|
728
2168
|
accessories.push(device.toAccessory());
|
|
729
2169
|
}
|
|
730
2170
|
if (accessories.length > 0) {
|
|
731
|
-
this.
|
|
2171
|
+
this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`);
|
|
732
2172
|
for (const acc of accessories) {
|
|
733
|
-
this.
|
|
2173
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
734
2174
|
}
|
|
735
2175
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
736
2176
|
}
|
|
@@ -739,9 +2179,9 @@ export class SwitchBotMatterPlatform {
|
|
|
739
2179
|
* Section 7: Sensors (Matter Spec § 7)
|
|
740
2180
|
*/
|
|
741
2181
|
async registerSection7Sensors() {
|
|
742
|
-
this.
|
|
743
|
-
this.
|
|
744
|
-
this.
|
|
2182
|
+
this.debugLog('═'.repeat(80));
|
|
2183
|
+
this.infoLog('Section 7: Sensors (Matter Spec § 7)');
|
|
2184
|
+
this.debugLog('═'.repeat(80));
|
|
745
2185
|
const accessories = [];
|
|
746
2186
|
// Contact Sensor
|
|
747
2187
|
if (this.config.enableContactSensor !== false) {
|
|
@@ -779,9 +2219,9 @@ export class SwitchBotMatterPlatform {
|
|
|
779
2219
|
accessories.push(device.toAccessory());
|
|
780
2220
|
}
|
|
781
2221
|
if (accessories.length > 0) {
|
|
782
|
-
this.
|
|
2222
|
+
this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`);
|
|
783
2223
|
for (const acc of accessories) {
|
|
784
|
-
this.
|
|
2224
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
785
2225
|
}
|
|
786
2226
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
787
2227
|
}
|
|
@@ -790,9 +2230,9 @@ export class SwitchBotMatterPlatform {
|
|
|
790
2230
|
* Section 8: Closure Devices (Matter Spec § 8)
|
|
791
2231
|
*/
|
|
792
2232
|
async registerSection8Closure() {
|
|
793
|
-
this.
|
|
794
|
-
this.
|
|
795
|
-
this.
|
|
2233
|
+
this.debugLog('═'.repeat(80));
|
|
2234
|
+
this.infoLog('Section 8: Closure Devices (Matter Spec § 8)');
|
|
2235
|
+
this.debugLog('═'.repeat(80));
|
|
796
2236
|
const accessories = [];
|
|
797
2237
|
// Door Lock
|
|
798
2238
|
if (this.config.enableDoorLock !== false) {
|
|
@@ -801,18 +2241,36 @@ export class SwitchBotMatterPlatform {
|
|
|
801
2241
|
}
|
|
802
2242
|
// Window Blind
|
|
803
2243
|
if (this.config.enableWindowBlind !== false) {
|
|
804
|
-
const device = new WindowBlindAccessory(this.api, this.log
|
|
2244
|
+
const device = new WindowBlindAccessory(this.api, this.log, {
|
|
2245
|
+
handlers: {
|
|
2246
|
+
windowCovering: {
|
|
2247
|
+
goToLiftPercentage: async (request) => device?.handleGoToLift?.(request),
|
|
2248
|
+
upOrOpen: async () => device?.handleUpOrOpen?.(),
|
|
2249
|
+
downOrClose: async () => device?.handleDownOrClose?.(),
|
|
2250
|
+
stopMotion: async () => device?.handleStop?.(),
|
|
2251
|
+
},
|
|
2252
|
+
},
|
|
2253
|
+
});
|
|
805
2254
|
accessories.push(device.toAccessory());
|
|
806
2255
|
}
|
|
807
2256
|
// Venetian Blind
|
|
808
2257
|
if (this.config.enableVenetianBlind !== false) {
|
|
809
|
-
const device = new VenetianBlindAccessory(this.api, this.log
|
|
2258
|
+
const device = new VenetianBlindAccessory(this.api, this.log, {
|
|
2259
|
+
handlers: {
|
|
2260
|
+
windowCovering: {
|
|
2261
|
+
goToLiftPercentage: async (request) => device?.handleGoToLift?.(request),
|
|
2262
|
+
upOrOpen: async () => device?.handleUpOrOpen?.(),
|
|
2263
|
+
downOrClose: async () => device?.handleDownOrClose?.(),
|
|
2264
|
+
stopMotion: async () => device?.handleStop?.(),
|
|
2265
|
+
},
|
|
2266
|
+
},
|
|
2267
|
+
});
|
|
810
2268
|
accessories.push(device.toAccessory());
|
|
811
2269
|
}
|
|
812
2270
|
if (accessories.length > 0) {
|
|
813
|
-
this.
|
|
2271
|
+
this.infoLog(`✓ Registered ${accessories.length} closure device(s)`);
|
|
814
2272
|
for (const acc of accessories) {
|
|
815
|
-
this.
|
|
2273
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
816
2274
|
}
|
|
817
2275
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
818
2276
|
}
|
|
@@ -821,9 +2279,9 @@ export class SwitchBotMatterPlatform {
|
|
|
821
2279
|
* Section 9: HVAC (Matter Spec § 9)
|
|
822
2280
|
*/
|
|
823
2281
|
async registerSection9HVAC() {
|
|
824
|
-
this.
|
|
825
|
-
this.
|
|
826
|
-
this.
|
|
2282
|
+
this.debugLog('═'.repeat(80));
|
|
2283
|
+
this.infoLog('Section 9: HVAC (Matter Spec § 9)');
|
|
2284
|
+
this.debugLog('═'.repeat(80));
|
|
827
2285
|
const accessories = [];
|
|
828
2286
|
// Thermostat
|
|
829
2287
|
if (this.config.enableThermostat !== false) {
|
|
@@ -836,9 +2294,9 @@ export class SwitchBotMatterPlatform {
|
|
|
836
2294
|
accessories.push(device.toAccessory());
|
|
837
2295
|
}
|
|
838
2296
|
if (accessories.length > 0) {
|
|
839
|
-
this.
|
|
2297
|
+
this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`);
|
|
840
2298
|
for (const acc of accessories) {
|
|
841
|
-
this.
|
|
2299
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
842
2300
|
}
|
|
843
2301
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
844
2302
|
}
|
|
@@ -850,9 +2308,9 @@ export class SwitchBotMatterPlatform {
|
|
|
850
2308
|
* Use those codes to pair the vacuum as a separate bridge in your Home app.
|
|
851
2309
|
*/
|
|
852
2310
|
async registerSection12Robotic() {
|
|
853
|
-
this.
|
|
854
|
-
this.
|
|
855
|
-
this.
|
|
2311
|
+
this.debugLog('═'.repeat(80));
|
|
2312
|
+
this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)');
|
|
2313
|
+
this.debugLog('═'.repeat(80));
|
|
856
2314
|
const accessories = [];
|
|
857
2315
|
// Robot Vacuum
|
|
858
2316
|
if (this.config.enableRobotVacuum !== false) {
|
|
@@ -860,9 +2318,9 @@ export class SwitchBotMatterPlatform {
|
|
|
860
2318
|
accessories.push(device.toAccessory());
|
|
861
2319
|
}
|
|
862
2320
|
if (accessories.length > 0) {
|
|
863
|
-
this.
|
|
2321
|
+
this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`);
|
|
864
2322
|
for (const acc of accessories) {
|
|
865
|
-
this.
|
|
2323
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
866
2324
|
}
|
|
867
2325
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
868
2326
|
}
|
|
@@ -875,9 +2333,9 @@ export class SwitchBotMatterPlatform {
|
|
|
875
2333
|
* like managing multiple logical components within a single device.
|
|
876
2334
|
*/
|
|
877
2335
|
async registerCustomDevices() {
|
|
878
|
-
this.
|
|
879
|
-
this.
|
|
880
|
-
this.
|
|
2336
|
+
this.debugLog('═'.repeat(80));
|
|
2337
|
+
this.infoLog('Custom Devices');
|
|
2338
|
+
this.debugLog('═'.repeat(80));
|
|
881
2339
|
const accessories = [];
|
|
882
2340
|
// Power Strip (4 Outlets)
|
|
883
2341
|
if (this.config.enablePowerStrip !== false) {
|
|
@@ -885,12 +2343,203 @@ export class SwitchBotMatterPlatform {
|
|
|
885
2343
|
accessories.push(device.toAccessory());
|
|
886
2344
|
}
|
|
887
2345
|
if (accessories.length > 0) {
|
|
888
|
-
this.
|
|
2346
|
+
this.infoLog(`✓ Registered ${accessories.length} custom device(s)`);
|
|
889
2347
|
for (const acc of accessories) {
|
|
890
|
-
this.
|
|
2348
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
891
2349
|
}
|
|
892
2350
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
893
2351
|
}
|
|
894
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Start platform-level refresh timer to batch all device status updates
|
|
2355
|
+
*/
|
|
2356
|
+
startPlatformRefreshTimer(refreshRateSec) {
|
|
2357
|
+
// Only create timer once
|
|
2358
|
+
if (this.platformRefreshTimer) {
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
// Respect user toggle
|
|
2362
|
+
if (this.config.options?.matterBatchEnabled === false) {
|
|
2363
|
+
this.infoLog('Matter batch refresh is disabled by configuration');
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
const jitterSec = Number(this.config.options?.matterBatchJitter ?? 0);
|
|
2367
|
+
const intervalMs = Number(refreshRateSec) * 1000;
|
|
2368
|
+
const jitterMs = Number.isFinite(jitterSec) && jitterSec > 0 ? Math.floor(Math.random() * jitterSec * 1000) : 0;
|
|
2369
|
+
// Start after optional jitter, then schedule recurring interval
|
|
2370
|
+
setTimeout(async () => {
|
|
2371
|
+
try {
|
|
2372
|
+
await this.batchRefreshAllDevices();
|
|
2373
|
+
}
|
|
2374
|
+
catch (e) {
|
|
2375
|
+
this.debugLog(`Initial batch refresh failed: ${e?.message ?? e}`);
|
|
2376
|
+
}
|
|
2377
|
+
this.platformRefreshTimer = setInterval(async () => {
|
|
2378
|
+
await this.batchRefreshAllDevices();
|
|
2379
|
+
}, intervalMs);
|
|
2380
|
+
}, jitterMs);
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Batch refresh all devices - still makes individual API calls but batches them together
|
|
2384
|
+
* Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
|
|
2385
|
+
*/
|
|
2386
|
+
async batchRefreshAllDevices() {
|
|
2387
|
+
if (!this.switchBotAPI) {
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
this.debugLog('Performing batched periodic OpenAPI refresh for all devices');
|
|
2391
|
+
// Build list from registered accessory instances (uuid) and discovered devices
|
|
2392
|
+
const devicesToRefresh = [];
|
|
2393
|
+
try {
|
|
2394
|
+
for (const [nid, instance] of this.accessoryInstances.entries()) {
|
|
2395
|
+
const uuid = instance?.uuid;
|
|
2396
|
+
if (!uuid) {
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid);
|
|
2400
|
+
// Skip devices with per-device timers and those in cooldown
|
|
2401
|
+
const now = Date.now();
|
|
2402
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
2403
|
+
if (dev && !this.perDeviceRefreshSet.has(nid) && now >= nextAllowed) {
|
|
2404
|
+
devicesToRefresh.push({ uuid, dev });
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
catch (e) {
|
|
2409
|
+
this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`);
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
if (devicesToRefresh.length === 0) {
|
|
2413
|
+
this.debugLog('No devices to refresh');
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`);
|
|
2417
|
+
// Randomize order to reduce synchronized spikes
|
|
2418
|
+
for (let i = devicesToRefresh.length - 1; i > 0; i--) {
|
|
2419
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2420
|
+
[devicesToRefresh[i], devicesToRefresh[j]] = [devicesToRefresh[j], devicesToRefresh[i]];
|
|
2421
|
+
}
|
|
2422
|
+
const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5);
|
|
2423
|
+
await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
|
|
2424
|
+
try {
|
|
2425
|
+
const status = await this.refreshSingleDeviceWithRetry(dev);
|
|
2426
|
+
if (status) {
|
|
2427
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
catch (e) {
|
|
2431
|
+
this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
2432
|
+
}
|
|
2433
|
+
}, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5);
|
|
2434
|
+
this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`);
|
|
2435
|
+
}
|
|
2436
|
+
/** Refresh a single device with retry and backoff; returns status object if successful */
|
|
2437
|
+
async refreshSingleDeviceWithRetry(dev, retries = 3, baseDelayMs = 500) {
|
|
2438
|
+
const deviceId = dev.deviceId;
|
|
2439
|
+
// Check API budget BEFORE attempting any retries - don't waste cycles on blocked requests
|
|
2440
|
+
if (!this.apiTracker?.trySpend('poll')) {
|
|
2441
|
+
// Don't log on every blocked request - the ApiRequestTracker handles periodic warnings
|
|
2442
|
+
return null;
|
|
2443
|
+
}
|
|
2444
|
+
let attempt = 0;
|
|
2445
|
+
while (attempt <= retries) {
|
|
2446
|
+
try {
|
|
2447
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
2448
|
+
const respAny = response;
|
|
2449
|
+
const body = respAny?.body ?? respAny;
|
|
2450
|
+
if (isSuccessfulStatusCode(statusCode)) {
|
|
2451
|
+
const status = body?.status ?? body;
|
|
2452
|
+
this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() });
|
|
2453
|
+
this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`);
|
|
2454
|
+
return status;
|
|
2455
|
+
}
|
|
2456
|
+
this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`);
|
|
2457
|
+
}
|
|
2458
|
+
catch (e) {
|
|
2459
|
+
this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`);
|
|
2460
|
+
}
|
|
2461
|
+
// backoff before next retry if any left
|
|
2462
|
+
attempt++;
|
|
2463
|
+
if (attempt <= retries) {
|
|
2464
|
+
const delay = baseDelayMs * (2 ** (attempt - 1));
|
|
2465
|
+
await sleep(delay);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
// Set a cooldown after exhausting retries to avoid hammering problematic devices
|
|
2469
|
+
try {
|
|
2470
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
2471
|
+
const cooldownMs = Math.max(60_000, baseDelayMs * (2 ** retries) * 10);
|
|
2472
|
+
this.backoffCooldowns.set(nid, Date.now() + cooldownMs);
|
|
2473
|
+
this.debugLog(`Applied cooldown for ${deviceId}: ${Math.round(cooldownMs / 1000)}s`);
|
|
2474
|
+
}
|
|
2475
|
+
catch { }
|
|
2476
|
+
return null;
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Validate that the user's configuration won't exceed API limits
|
|
2480
|
+
* Warn if device count × polling frequency will hit daily limits
|
|
2481
|
+
*/
|
|
2482
|
+
validateApiUsageConfig(deviceCount, irDeviceCount) {
|
|
2483
|
+
try {
|
|
2484
|
+
const totalDevices = deviceCount + irDeviceCount;
|
|
2485
|
+
if (totalDevices === 0) {
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
// For Matter platform, use matterBatchRefreshRate or fallback to refreshRate
|
|
2489
|
+
const refreshRate = this.getPlatformBatchInterval(); // seconds
|
|
2490
|
+
const dailyLimit = this.config.options?.dailyApiLimit ?? 10000;
|
|
2491
|
+
const reserveForCommands = this.config.options?.dailyApiReserveForCommands ?? 1000;
|
|
2492
|
+
// Calculate polls per day (86400 seconds in a day)
|
|
2493
|
+
const pollsPerDevicePerDay = Math.floor(86400 / refreshRate);
|
|
2494
|
+
const totalPollsPerDay = pollsPerDevicePerDay * totalDevices;
|
|
2495
|
+
// Add discovery calls (typically 1-2 per day)
|
|
2496
|
+
const estimatedDiscoveryCalls = 2;
|
|
2497
|
+
const totalEstimatedCalls = totalPollsPerDay + estimatedDiscoveryCalls;
|
|
2498
|
+
const usableLimit = dailyLimit - reserveForCommands;
|
|
2499
|
+
const percentOfLimit = Math.round((totalEstimatedCalls / usableLimit) * 100);
|
|
2500
|
+
this.debugLog(`[API Usage Diagnostic] ${totalDevices} devices × ${pollsPerDevicePerDay} polls/day = ${totalPollsPerDay} estimated daily polls`);
|
|
2501
|
+
this.debugLog(`[API Usage Diagnostic] With ${reserveForCommands} reserved for commands, usable limit is ${usableLimit}`);
|
|
2502
|
+
if (totalEstimatedCalls > dailyLimit) {
|
|
2503
|
+
this.errorLog(`⚠️ API LIMIT WARNING: Your configuration will exceed the daily API limit!`);
|
|
2504
|
+
this.errorLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`);
|
|
2505
|
+
this.errorLog(` Daily limit: ${dailyLimit} | You will use ${percentOfLimit}% of available budget`);
|
|
2506
|
+
this.errorLog(` SOLUTION: Increase matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)} seconds or higher`);
|
|
2507
|
+
this.errorLog(` OR: Enable webhooks and set 'webhookOnlyOnReserve: true' to reduce polling`);
|
|
2508
|
+
}
|
|
2509
|
+
else if (totalEstimatedCalls > usableLimit) {
|
|
2510
|
+
this.warnLog(`⚠️ API USAGE WARNING: Configuration may exceed usable daily API budget`);
|
|
2511
|
+
this.warnLog(` Devices: ${totalDevices} | Refresh rate: ${refreshRate}s | Estimated daily polls: ${totalEstimatedCalls}`);
|
|
2512
|
+
this.warnLog(` Usable limit (after reserve): ${usableLimit} | You will use ${percentOfLimit}% of budget`);
|
|
2513
|
+
this.warnLog(` Polling may pause when approaching limit. Consider increasing matterBatchRefreshRate to ${Math.ceil((totalDevices * 86400) / usableLimit)}s`);
|
|
2514
|
+
}
|
|
2515
|
+
else if (percentOfLimit > 75) {
|
|
2516
|
+
this.infoLog(`[API Usage] Using ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls). Monitor usage if adding more devices.`);
|
|
2517
|
+
}
|
|
2518
|
+
else {
|
|
2519
|
+
this.debugLog(`[API Usage] Configuration looks good: ${percentOfLimit}% of daily budget (${totalEstimatedCalls}/${usableLimit} calls)`);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
catch (e) {
|
|
2523
|
+
this.debugLog(`Failed to validate API usage config: ${e?.message ?? e}`);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
/** Simple concurrency limiter for an array of items */
|
|
2527
|
+
async runWithConcurrency(items, worker, concurrency) {
|
|
2528
|
+
const queue = items.slice();
|
|
2529
|
+
const workers = [];
|
|
2530
|
+
const runNext = async () => {
|
|
2531
|
+
const item = queue.shift();
|
|
2532
|
+
if (!item) {
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
await worker(item);
|
|
2536
|
+
return runNext();
|
|
2537
|
+
};
|
|
2538
|
+
const pool = Math.min(concurrency, Math.max(1, items.length));
|
|
2539
|
+
for (let i = 0; i < pool; i++) {
|
|
2540
|
+
workers.push(runNext());
|
|
2541
|
+
}
|
|
2542
|
+
await Promise.all(workers);
|
|
2543
|
+
}
|
|
895
2544
|
}
|
|
896
2545
|
//# sourceMappingURL=platform-matter.js.map
|