@switchbot/homebridge-switchbot 5.0.0-beta.3 → 5.0.0-beta.31
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 +13 -0
- package/README.md +23 -3
- package/config.schema.json +70 -4
- package/dist/devices-hap/device.d.ts +1 -0
- package/dist/devices-hap/device.d.ts.map +1 -1
- package/dist/devices-hap/device.js +70 -30
- package/dist/devices-hap/device.js.map +1 -1
- package/dist/devices-matter/BaseMatterAccessory.d.ts +23 -0
- package/dist/devices-matter/BaseMatterAccessory.d.ts.map +1 -1
- package/dist/devices-matter/BaseMatterAccessory.js +167 -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/homebridge-ui/public/index.html +48 -1
- package/dist/homebridge-ui/server.js +35 -0
- package/dist/homebridge-ui/server.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +7 -2
- package/dist/index.test.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 +11 -14
- package/dist/platform-hap.d.ts.map +1 -1
- package/dist/platform-hap.js +64 -64
- package/dist/platform-hap.js.map +1 -1
- package/dist/platform-matter.d.ts +87 -6
- package/dist/platform-matter.d.ts.map +1 -1
- package/dist/platform-matter.js +1845 -84
- package/dist/platform-matter.js.map +1 -1
- package/dist/settings.d.ts +11 -0
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js.map +1 -1
- 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/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/utils.d.ts +127 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +405 -0
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.d.ts +2 -0
- package/dist/utils.test.d.ts.map +1 -0
- package/dist/utils.test.js +95 -0
- package/dist/utils.test.js.map +1 -0
- package/dist/verifyconfig.test.js +2 -2
- package/dist/verifyconfig.test.js.map +1 -1
- package/docs/assets/main.js +2 -2
- package/docs/index.html +20 -2
- package/docs/variables/default.html +1 -1
- package/package.json +14 -14
- package/src/devices-hap/device.ts +68 -30
- package/src/devices-matter/BaseMatterAccessory.ts +168 -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/homebridge-ui/public/index.html +48 -1
- package/src/homebridge-ui/server.ts +37 -0
- package/src/index.test.ts +7 -2
- package/src/index.ts +4 -5
- package/src/irdevice/irdevice.ts +74 -35
- package/src/platform-hap.ts +68 -73
- package/src/platform-matter.ts +1879 -87
- package/src/settings.ts +15 -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/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/utils.test.ts +96 -0
- package/src/utils.ts +419 -3
- package/src/verifyconfig.test.ts +11 -10
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 { cleanDeviceConfig, sleep } from './utils.js';
|
|
5
|
-
/**
|
|
6
|
-
* MatterPlatform
|
|
7
|
-
* Demonstrates all available Matter device types in Homebridge
|
|
8
|
-
*
|
|
9
|
-
* Organized by official Matter Specification v1.4.1 categories
|
|
10
|
-
*/
|
|
7
|
+
import { ApiRequestTracker, cleanDeviceConfig, createPlatformLogger, formatDeviceIdAsMac, hs2rgb, makeBLESender, makeOpenAPISender, rgb2hs, sleep } from './utils.js';
|
|
11
8
|
export class SwitchBotMatterPlatform {
|
|
12
9
|
log;
|
|
13
10
|
config;
|
|
@@ -22,11 +19,73 @@ export class SwitchBotMatterPlatform {
|
|
|
22
19
|
switchBotBLE;
|
|
23
20
|
// discovered devices cache
|
|
24
21
|
discoveredDevices = [];
|
|
22
|
+
// Registry of created accessory instances keyed by normalized deviceId
|
|
23
|
+
accessoryInstances = new Map();
|
|
24
|
+
// Refresh timers keyed by normalized deviceId
|
|
25
|
+
refreshTimers = new Map();
|
|
26
|
+
// Platform-level refresh timer for batch status updates
|
|
27
|
+
platformRefreshTimer;
|
|
28
|
+
// Device status cache from last refresh
|
|
29
|
+
deviceStatusCache = new Map();
|
|
30
|
+
// Backoff cooldowns persisted across restarts: normalized deviceId -> nextAllowedAt(ms)
|
|
31
|
+
backoffCooldowns = new Map();
|
|
32
|
+
backoffFilePath;
|
|
33
|
+
// Devices that have explicit per-device refresh timers (normalized deviceId)
|
|
34
|
+
perDeviceRefreshSet = new Set();
|
|
35
|
+
// BLE event handlers keyed by device MAC (formatted)
|
|
36
|
+
bleEventHandler = {};
|
|
37
|
+
// MQTT and Webhook properties (mirror HAP behavior so Matter platform can
|
|
38
|
+
// receive OpenAPI webhook events and/or MQTT-proxied webhook messages)
|
|
39
|
+
mqttClient = null;
|
|
40
|
+
webhookEventListener = null;
|
|
41
|
+
webhookEventHandler = {};
|
|
42
|
+
// Platform logging toggle (can be controlled via UI or config)
|
|
43
|
+
// Use same shape as HAP platform: string values like 'debug', 'debugMode', 'standard', or 'none'
|
|
44
|
+
platformLogging;
|
|
45
|
+
// API request tracking (persistent across restarts)
|
|
46
|
+
apiTracker;
|
|
47
|
+
// Platform-provided logging helpers (attached in constructor)
|
|
48
|
+
infoLog;
|
|
49
|
+
successLog;
|
|
50
|
+
debugSuccessLog;
|
|
51
|
+
warnLog;
|
|
52
|
+
debugWarnLog;
|
|
53
|
+
errorLog;
|
|
54
|
+
debugErrorLog;
|
|
55
|
+
debugLog;
|
|
56
|
+
loggingIsDebug;
|
|
57
|
+
enablingPlatformLogging;
|
|
25
58
|
constructor(log, config, api) {
|
|
26
59
|
this.log = log;
|
|
27
60
|
this.config = config;
|
|
28
61
|
this.api = api;
|
|
29
|
-
|
|
62
|
+
// Determine platform logging preference (match HAP behaviour as closely as
|
|
63
|
+
// possible using config values. We default to 'standard' when unspecified.)
|
|
64
|
+
this.platformLogging = (this.config.options?.logging === 'debug' || this.config.options?.logging === 'standard' || this.config.options?.logging === 'none')
|
|
65
|
+
? this.config.options.logging
|
|
66
|
+
: 'standard';
|
|
67
|
+
// Unconditional diagnostic using the raw Homebridge `log` so it always
|
|
68
|
+
// appears regardless of the platform logging helpers' gating logic.
|
|
69
|
+
try {
|
|
70
|
+
this.log.debug?.(`[SwitchBot Matter] effective platformLogging=${String(this.platformLogging)}`);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
// swallow any logging errors — diagnostics are best-effort
|
|
74
|
+
}
|
|
75
|
+
// Attach platform-wide logging helpers from utils so Matter and device
|
|
76
|
+
// classes can use consistent logging methods (infoLog/debugLog/etc.)
|
|
77
|
+
const _pl = createPlatformLogger(async () => this.platformLogging, this.log);
|
|
78
|
+
this.infoLog = _pl.infoLog;
|
|
79
|
+
this.successLog = _pl.successLog;
|
|
80
|
+
this.debugSuccessLog = _pl.debugSuccessLog;
|
|
81
|
+
this.warnLog = _pl.warnLog;
|
|
82
|
+
this.debugWarnLog = _pl.debugWarnLog;
|
|
83
|
+
this.errorLog = _pl.errorLog;
|
|
84
|
+
this.debugErrorLog = _pl.debugErrorLog;
|
|
85
|
+
this.debugLog = _pl.debugLog;
|
|
86
|
+
this.loggingIsDebug = _pl.loggingIsDebug;
|
|
87
|
+
this.enablingPlatformLogging = _pl.enablingPlatformLogging;
|
|
88
|
+
this.debugLog('Finished initializing platform:', this.config.name);
|
|
30
89
|
// Normalize deviceConfig to remove UI-inserted defaults
|
|
31
90
|
try {
|
|
32
91
|
if (this.config.options) {
|
|
@@ -41,11 +100,11 @@ export class SwitchBotMatterPlatform {
|
|
|
41
100
|
}
|
|
42
101
|
}
|
|
43
102
|
catch (e) {
|
|
44
|
-
this.
|
|
103
|
+
this.debugLog('Failed to clean deviceConfig: %s', e);
|
|
45
104
|
}
|
|
46
105
|
// Does the user have a version of Homebridge that is compatible with matter?
|
|
47
106
|
if (!this.api.isMatterAvailable?.()) {
|
|
48
|
-
this.
|
|
107
|
+
this.warnLog('Matter is not available in this version of Homebridge. Please update Homebridge to use this plugin.');
|
|
49
108
|
}
|
|
50
109
|
// Check if the user has matter enabled, this means:
|
|
51
110
|
// - If the plugin is running on the main bridge, then the user must have enabled matter in the Homebridge settings page in the UI
|
|
@@ -53,67 +112,1013 @@ export class SwitchBotMatterPlatform {
|
|
|
53
112
|
// In reality, only the below check is needed, but they are both included here for completeness
|
|
54
113
|
// Remember to use a '?.' optional chaining operator in case the user is running an older version of Homebridge that does not have these APIs
|
|
55
114
|
if (!this.api.isMatterEnabled?.()) {
|
|
56
|
-
this.
|
|
115
|
+
this.warnLog('Matter is not enabled in Homebridge. Please enable Matter in the Homebridge settings to use this plugin.');
|
|
57
116
|
return;
|
|
58
117
|
}
|
|
59
118
|
// Register Matter accessories when Homebridge has finished launching
|
|
60
119
|
this.api.on('didFinishLaunching', () => {
|
|
61
|
-
this.
|
|
120
|
+
this.debugLog('Executed didFinishLaunching callback');
|
|
121
|
+
// Log presence of credentials (do not log actual values)
|
|
122
|
+
try {
|
|
123
|
+
this.debugLog(`SwitchBot credentials present? token=${Boolean(this.config.credentials?.token)}, secret=${Boolean(this.config.credentials?.secret)}`);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
this.debugLog('Failed to log credentials presence: %s', e?.message ?? e);
|
|
127
|
+
}
|
|
128
|
+
// Do not fall back to environment variables here — credentials must
|
|
129
|
+
// come from plugin config (Homebridge UI). If credentials appear
|
|
130
|
+
// missing we'll log that fact and continue; the UI should store token
|
|
131
|
+
// and secret in `config.credentials`.
|
|
62
132
|
// Initialize SwitchBot API clients
|
|
63
133
|
try {
|
|
64
134
|
if (this.config.credentials?.token && this.config.credentials?.secret) {
|
|
65
135
|
this.switchBotAPI = new SwitchBotOpenAPI(this.config.credentials.token, this.config.credentials.secret, this.config.options?.hostname);
|
|
66
136
|
// forward basic logs
|
|
67
137
|
if (!this.config.options?.disableLogsforOpenAPI && this.switchBotAPI?.on) {
|
|
68
|
-
this.switchBotAPI.on('log', (l) => this.
|
|
138
|
+
this.switchBotAPI.on('log', (l) => this.debugLog('[SwitchBot OpenAPI]', l.message));
|
|
69
139
|
}
|
|
70
140
|
}
|
|
71
141
|
else {
|
|
72
|
-
this.
|
|
142
|
+
this.debugLog('SwitchBot OpenAPI credentials not provided; cloud devices will be skipped');
|
|
73
143
|
}
|
|
74
144
|
}
|
|
75
145
|
catch (e) {
|
|
76
|
-
this.
|
|
146
|
+
this.errorLog('Failed to initialize SwitchBot OpenAPI:', e?.message ?? e);
|
|
147
|
+
}
|
|
148
|
+
// Initialize API request tracking
|
|
149
|
+
try {
|
|
150
|
+
this.apiTracker = new ApiRequestTracker(this.api, this.log, 'SwitchBot Matter');
|
|
151
|
+
this.apiTracker.startHourlyLogging();
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
this.errorLog('Failed to initialize API request tracking:', e?.message ?? e);
|
|
77
155
|
}
|
|
78
156
|
try {
|
|
79
157
|
this.switchBotBLE = new SwitchBotBLE();
|
|
80
158
|
if (!this.config.options?.disableLogsforBLE && this.switchBotBLE?.on) {
|
|
81
|
-
this.switchBotBLE.on('log', (l) => this.
|
|
159
|
+
this.switchBotBLE.on('log', (l) => this.debugLog('[SwitchBot BLE]', l.message));
|
|
82
160
|
}
|
|
83
161
|
}
|
|
84
162
|
catch (e) {
|
|
85
|
-
this.
|
|
163
|
+
this.errorLog('Failed to initialize SwitchBot BLE client:', e?.message ?? e);
|
|
86
164
|
}
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
// If BLE scanning is enabled, start scanning and route advertisements to registered handlers
|
|
166
|
+
if (this.config.options?.BLE && this.switchBotBLE) {
|
|
167
|
+
const ble = this.switchBotBLE;
|
|
168
|
+
(async () => {
|
|
169
|
+
try {
|
|
170
|
+
await ble.startScan();
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
this.errorLog(`Failed to start BLE scanning: ${e?.message ?? e}`);
|
|
174
|
+
}
|
|
175
|
+
// route advertisements to our handlers
|
|
176
|
+
ble.onadvertisement = async (ad) => {
|
|
177
|
+
try {
|
|
178
|
+
const mac = (ad.address || '').toLowerCase();
|
|
179
|
+
const handler = this.bleEventHandler[mac];
|
|
180
|
+
if (handler) {
|
|
181
|
+
await handler(ad.serviceData);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
this.errorLog(`Failed to handle BLE advertisement: ${e?.message ?? e}`);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
})();
|
|
189
|
+
}
|
|
190
|
+
// Ensure we clean up any per-device timers and BLE handlers when Homebridge shuts down
|
|
191
|
+
this.api.on('shutdown', async () => {
|
|
192
|
+
try {
|
|
193
|
+
this.infoLog('Homebridge shutting down: clearing refresh timers and BLE handlers');
|
|
194
|
+
// Stop API tracking hourly logging
|
|
195
|
+
if (this.apiTracker) {
|
|
196
|
+
this.apiTracker.stopHourlyLogging();
|
|
197
|
+
}
|
|
198
|
+
// Clear all refresh timers
|
|
199
|
+
for (const [nid, t] of Array.from(this.refreshTimers.entries())) {
|
|
200
|
+
try {
|
|
201
|
+
clearInterval(t);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
this.debugLog(`Failed to clear timer for ${nid}: ${e?.message ?? e}`);
|
|
205
|
+
}
|
|
206
|
+
this.refreshTimers.delete(nid);
|
|
207
|
+
}
|
|
208
|
+
// Clear platform-level refresh timer
|
|
209
|
+
try {
|
|
210
|
+
if (this.platformRefreshTimer) {
|
|
211
|
+
clearInterval(this.platformRefreshTimer);
|
|
212
|
+
this.platformRefreshTimer = undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
this.debugLog(`Failed to clear platform refresh timer: ${e?.message ?? e}`);
|
|
217
|
+
}
|
|
218
|
+
// Clear accessory instances registry
|
|
219
|
+
try {
|
|
220
|
+
this.accessoryInstances.clear();
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
this.debugLog(`Failed to clear accessoryInstances: ${e?.message ?? e}`);
|
|
224
|
+
}
|
|
225
|
+
// Remove BLE handlers
|
|
226
|
+
try {
|
|
227
|
+
for (const k of Object.keys(this.bleEventHandler)) {
|
|
228
|
+
delete this.bleEventHandler[k];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
this.debugLog(`Failed to clear bleEventHandler: ${e?.message ?? e}`);
|
|
233
|
+
}
|
|
234
|
+
// Stop BLE scanning if available
|
|
235
|
+
try {
|
|
236
|
+
if (this.switchBotBLE && typeof this.switchBotBLE.stopScan === 'function') {
|
|
237
|
+
await this.switchBotBLE.stopScan();
|
|
238
|
+
this.infoLog('Stopped BLE scanning');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
this.debugLog(`Failed to stop BLE scanning: ${e?.message ?? e}`);
|
|
243
|
+
}
|
|
244
|
+
// Persist backoff cooldowns to disk
|
|
245
|
+
try {
|
|
246
|
+
if (this.backoffFilePath) {
|
|
247
|
+
const obj = {};
|
|
248
|
+
for (const [k, v] of this.backoffCooldowns.entries()) {
|
|
249
|
+
if (Number.isFinite(v)) {
|
|
250
|
+
obj[k] = v;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
writeFileSync(this.backoffFilePath, JSON.stringify(obj, null, 2));
|
|
254
|
+
this.debugLog(`Saved ${Object.keys(obj).length} backoff cooldown entries`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
this.debugLog(`Failed to save backoff state: ${e?.message ?? e}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
this.debugLog('Shutdown cleanup failed: %s', e?.message ?? e);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
(async () => {
|
|
266
|
+
try {
|
|
267
|
+
await this.discoverDevices();
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
this.debugLog('Device discovery failed during startup: %s', e?.message ?? e);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await this.registerMatterAccessories();
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
this.errorLog('Failed to register Matter accessories: %s', e?.message ?? e);
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
90
279
|
});
|
|
280
|
+
try {
|
|
281
|
+
this.setupMqtt();
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
this.errorLog(`Setup MQTT, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
this.setupwebhook();
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
this.errorLog(`Setup Webhook, Error Message: ${e?.message ?? e}, Submit Bugs Here: ` + 'https://tinyurl.com/SwitchBotBug');
|
|
291
|
+
}
|
|
292
|
+
// Initialize backoff persistence file path and load any saved cooldowns
|
|
293
|
+
try {
|
|
294
|
+
const storagePath = this.api.user.storagePath();
|
|
295
|
+
this.backoffFilePath = join(storagePath, `${PLUGIN_NAME.replace('@', '').replace('/', '-')}-matter-backoff.json`);
|
|
296
|
+
if (existsSync(this.backoffFilePath)) {
|
|
297
|
+
try {
|
|
298
|
+
const raw = readFileSync(this.backoffFilePath, 'utf8');
|
|
299
|
+
const parsed = JSON.parse(raw);
|
|
300
|
+
if (parsed && typeof parsed === 'object') {
|
|
301
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
302
|
+
const ts = Number(v);
|
|
303
|
+
if (Number.isFinite(ts)) {
|
|
304
|
+
this.backoffCooldowns.set(String(k), ts);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
this.debugLog(`Loaded ${this.backoffCooldowns.size} backoff cooldown entries`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
this.debugLog(`Failed to load backoff state: ${e?.message ?? e}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
this.debugLog(`Failed to initialize backoff persistence: ${e?.message ?? e}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Normalize a deviceId for matching (uppercase alphanumerics only)
|
|
321
|
+
*/
|
|
322
|
+
normalizeDeviceId(deviceId) {
|
|
323
|
+
return (deviceId ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '');
|
|
324
|
+
}
|
|
325
|
+
/** Determine the platform-level batch interval in seconds */
|
|
326
|
+
getPlatformBatchInterval() {
|
|
327
|
+
const opt = this.config.options;
|
|
328
|
+
const val = opt?.matterBatchRefreshRate ?? opt?.refreshRate ?? 300;
|
|
329
|
+
const n = Number(val);
|
|
330
|
+
return Number.isFinite(n) && n > 0 ? n : 300;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Clear per-device resources: refresh timers, accessory instance registry, BLE handlers
|
|
334
|
+
*/
|
|
335
|
+
clearDeviceResources(deviceId) {
|
|
336
|
+
if (!deviceId) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
341
|
+
const existing = this.refreshTimers.get(nid);
|
|
342
|
+
if (existing) {
|
|
343
|
+
try {
|
|
344
|
+
clearInterval(existing);
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
this.debugLog(`Failed to clear refresh timer for ${deviceId}: ${e?.message ?? e}`);
|
|
348
|
+
}
|
|
349
|
+
this.refreshTimers.delete(nid);
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
this.accessoryInstances.delete(nid);
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
this.debugLog(`Failed to delete accessory instance for ${deviceId}: ${e?.message ?? e}`);
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const mac = formatDeviceIdAsMac(deviceId).toLowerCase();
|
|
359
|
+
if (this.bleEventHandler[mac]) {
|
|
360
|
+
delete this.bleEventHandler[mac];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
// formatting failed (not a MAC-like id) — ignore
|
|
365
|
+
this.debugLog(`clearDeviceResources: failed to remove BLE handler for ${deviceId}: ${e?.message ?? e}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
this.debugLog(`clearDeviceResources top-level error for ${deviceId}: ${e?.message ?? e}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Merge two arrays by deviceId. For each item in a1 (user-provided devices list),
|
|
374
|
+
* find matching item in a2 (discovered devices) and merge them with user overrides last.
|
|
375
|
+
*/
|
|
376
|
+
mergeByDeviceId(a1, a2) {
|
|
377
|
+
const allowConfigOnly = Boolean(this.config.options?.allowConfigOnlyDevices);
|
|
378
|
+
const result = [];
|
|
379
|
+
for (const itm of (a1 || [])) {
|
|
380
|
+
const matchingItem = (a2 || []).find(item => this.normalizeDeviceId(item.deviceId) === this.normalizeDeviceId(itm.deviceId));
|
|
381
|
+
if (matchingItem) {
|
|
382
|
+
result.push(Object.assign({}, matchingItem, itm));
|
|
383
|
+
}
|
|
384
|
+
else if (allowConfigOnly) {
|
|
385
|
+
// include config-only device as-is when explicitly allowed
|
|
386
|
+
result.push(Object.assign({}, itm));
|
|
387
|
+
}
|
|
388
|
+
// otherwise skip config-only entries
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Merge discovered devices with deviceConfig (per deviceType) and per-device overrides
|
|
394
|
+
* from `config.options.devices`, matching the behavior used in platform-hap.
|
|
395
|
+
*/
|
|
396
|
+
async mergeDiscoveredDevices(discovered) {
|
|
397
|
+
// If there's no device config or per-device config, return discovered as-is
|
|
398
|
+
if (!this.config.options?.devices && !this.config.options?.deviceConfig) {
|
|
399
|
+
return discovered;
|
|
400
|
+
}
|
|
401
|
+
// Step 1: Assign missing deviceType from configDeviceType and merge deviceType-level configs
|
|
402
|
+
const devicesWithTypeConfig = await Promise.all(discovered.map(async (deviceObj) => {
|
|
403
|
+
if (!deviceObj.deviceType) {
|
|
404
|
+
deviceObj.deviceType = deviceObj.configDeviceType !== undefined ? deviceObj.configDeviceType : 'Unknown';
|
|
405
|
+
this.debugLog(`API missing deviceType for ${deviceObj.deviceId}, using configDeviceType: ${deviceObj.configDeviceType}`);
|
|
406
|
+
}
|
|
407
|
+
const deviceTypeConfig = this.config.options?.deviceConfig?.[deviceObj.deviceType] || {};
|
|
408
|
+
return Object.assign({}, deviceObj, deviceTypeConfig);
|
|
409
|
+
}));
|
|
410
|
+
// Merge per-device overrides by matching deviceId
|
|
411
|
+
const merged = this.mergeByDeviceId(this.config.options?.devices ?? [], devicesWithTypeConfig ?? []);
|
|
412
|
+
// For any entries in merged (which are based on config.options.devices), ensure final per-device merges include deviceId-specific config
|
|
413
|
+
const final = [];
|
|
414
|
+
for (const device of merged) {
|
|
415
|
+
// Find per-device config entry by deviceId (config.options.devices is an array)
|
|
416
|
+
const deviceIdConfig = (this.config.options?.devices || []).find((d) => this.normalizeDeviceId(d.deviceId) === this.normalizeDeviceId(device.deviceId)) || {};
|
|
417
|
+
const deviceWithConfig = Object.assign({}, device, deviceIdConfig);
|
|
418
|
+
final.push(deviceWithConfig);
|
|
419
|
+
}
|
|
420
|
+
// Also include any discovered devices that weren't present in the user devices list
|
|
421
|
+
const userDeviceIds = new Set((this.config.options?.devices || []).map((d) => this.normalizeDeviceId(d.deviceId)));
|
|
422
|
+
for (const d of devicesWithTypeConfig) {
|
|
423
|
+
if (!userDeviceIds.has(this.normalizeDeviceId(d.deviceId))) {
|
|
424
|
+
final.push(d);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return final;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Select effective connection type for a device: prefer explicit device.connectionType,
|
|
431
|
+
* otherwise prefer BLE when platform BLE is enabled and device provides a BLE model/id.
|
|
432
|
+
*/
|
|
433
|
+
chooseConnectionType(deviceObj) {
|
|
434
|
+
if (deviceObj?.connectionType) {
|
|
435
|
+
return deviceObj.connectionType === 'BLE' ? 'BLE' : 'OpenAPI';
|
|
436
|
+
}
|
|
437
|
+
// If platform BLE is enabled and we have a bleModel or deviceId that formats to a MAC, prefer BLE
|
|
438
|
+
if (this.config.options?.BLE && (deviceObj?.bleModel || formatDeviceIdAsMac(deviceObj?.deviceId))) {
|
|
439
|
+
return 'BLE';
|
|
440
|
+
}
|
|
441
|
+
return 'OpenAPI';
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Map a SwitchBot device object to a MatterAccessory using the device-specific
|
|
445
|
+
* Matter accessory classes in `src/devices-matter`.
|
|
446
|
+
*/
|
|
447
|
+
async createAccessoryFromDevice(dev) {
|
|
448
|
+
// Basic metadata
|
|
449
|
+
const displayName = dev.deviceName ?? dev.deviceId ?? 'SwitchBot Device';
|
|
450
|
+
const serial = dev.deviceId ?? 'unknown';
|
|
451
|
+
const manufacturer = 'SwitchBot';
|
|
452
|
+
const model = dev.model ?? dev.deviceType ?? 'SwitchBot';
|
|
453
|
+
const firmware = dev.firmware ?? dev.version ?? '0.0.0';
|
|
454
|
+
// Helper to build a default opts object consumed by the matter device classes
|
|
455
|
+
const baseOpts = {
|
|
456
|
+
uuid: this.api.matter.uuid.generate(serial),
|
|
457
|
+
displayName,
|
|
458
|
+
serialNumber: serial,
|
|
459
|
+
manufacturer,
|
|
460
|
+
model,
|
|
461
|
+
firmwareRevision: String(firmware),
|
|
462
|
+
hardwareRevision: '1.0.0',
|
|
463
|
+
deviceId: dev.deviceId,
|
|
464
|
+
// Inject handy platform-side helpers into the accessory `context` so Matter
|
|
465
|
+
// accessory classes can perform OpenAPI/BLE actions without reaching into
|
|
466
|
+
// the platform implementation directly.
|
|
467
|
+
context: {
|
|
468
|
+
deviceId: dev.deviceId,
|
|
469
|
+
// Expose the display name so Matter accessory classes can read it from context
|
|
470
|
+
name: displayName,
|
|
471
|
+
// Provide device-level logging override (if present) and platform logging flag
|
|
472
|
+
// so accessories can decide how verbose they should be.
|
|
473
|
+
deviceLogging: dev?.logging,
|
|
474
|
+
platformLogging: this.platformLogging,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
// Build platform-side helpers using shared factories so they can be reused/tested
|
|
478
|
+
const sendOpenAPI = makeOpenAPISender(this.retryCommand.bind(this), dev, { maxRetries: this.config.options?.maxRetries ?? 1, delayBetweenRetries: this.config.options?.delayBetweenRetries ?? 1000 });
|
|
479
|
+
const sendBLE = makeBLESender(this.switchBotBLE, dev, { bleRetries: this.config.options?.bleRetries ?? 2, bleRetryDelay: this.config.options?.bleRetryDelay ?? 500 });
|
|
480
|
+
// Log that we're initializing this device so it's visible in startup logs
|
|
481
|
+
try {
|
|
482
|
+
this.infoLog(`Initializing Matter device: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
// best-effort logging — swallow errors to avoid breaking initialization
|
|
486
|
+
this.debugLog('Failed to log initializing device:', e?.message ?? e);
|
|
487
|
+
}
|
|
488
|
+
const makeOnOffHandlers = (uuid, connectionType) => ({
|
|
489
|
+
onOff: {
|
|
490
|
+
on: async () => {
|
|
491
|
+
try {
|
|
492
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
493
|
+
await sendBLE('turnOn');
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
await sendOpenAPI('turnOn');
|
|
497
|
+
}
|
|
498
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: true });
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
this.errorLog(`Failed to turn on device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
off: async () => {
|
|
505
|
+
try {
|
|
506
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
507
|
+
await sendBLE('turnOff');
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
await sendOpenAPI('turnOff');
|
|
511
|
+
}
|
|
512
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.OnOff, { onOff: false });
|
|
513
|
+
}
|
|
514
|
+
catch (e) {
|
|
515
|
+
this.errorLog(`Failed to turn off device ${dev.deviceId}: ${e?.message ?? e}`);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
// Mapping from SwitchBot deviceType -> constructor (expanded for parity with HAP)
|
|
521
|
+
const mapping = {
|
|
522
|
+
// Plugs / Outlets
|
|
523
|
+
'Plug': OnOffOutletAccessory,
|
|
524
|
+
'Plug Mini (US)': OnOffOutletAccessory,
|
|
525
|
+
'Plug Mini (JP)': OnOffOutletAccessory,
|
|
526
|
+
'Plug Mini': OnOffOutletAccessory,
|
|
527
|
+
'WoPlug': OnOffOutletAccessory,
|
|
528
|
+
// Lighting
|
|
529
|
+
'Color Bulb': ColorLightAccessory,
|
|
530
|
+
'Color Bulb Mini': ColorLightAccessory,
|
|
531
|
+
'Ceiling Light': ColorTemperatureLightAccessory,
|
|
532
|
+
'Ceiling Light Pro': ColorTemperatureLightAccessory,
|
|
533
|
+
'Strip Light': ExtendedColorLightAccessory,
|
|
534
|
+
'Light Strip': ExtendedColorLightAccessory,
|
|
535
|
+
'Light Strip Plus': ExtendedColorLightAccessory,
|
|
536
|
+
'Strip Light Pro': ExtendedColorLightAccessory,
|
|
537
|
+
'Dimmable Light': DimmableLightAccessory,
|
|
538
|
+
// Robot Vacuums
|
|
539
|
+
'K10+': RoboticVacuumAccessory,
|
|
540
|
+
'K10+ Pro': RoboticVacuumAccessory,
|
|
541
|
+
'WoSweeper': RoboticVacuumAccessory,
|
|
542
|
+
'WoSweeperMini': RoboticVacuumAccessory,
|
|
543
|
+
'Robot Vacuum Cleaner S1': RoboticVacuumAccessory,
|
|
544
|
+
'Robot Vacuum Cleaner S1 Plus': RoboticVacuumAccessory,
|
|
545
|
+
'Robot Vacuum Cleaner S10': RoboticVacuumAccessory,
|
|
546
|
+
'Robot Vacuum Cleaner S1 Pro': RoboticVacuumAccessory,
|
|
547
|
+
'Robot Vacuum Cleaner S1 Mini': RoboticVacuumAccessory,
|
|
548
|
+
// Locks
|
|
549
|
+
'Smart Lock': DoorLockAccessory,
|
|
550
|
+
'Smart Lock Pro': DoorLockAccessory,
|
|
551
|
+
// Sensors
|
|
552
|
+
'Motion Sensor': OccupancySensorAccessory,
|
|
553
|
+
'Contact Sensor': ContactSensorAccessory,
|
|
554
|
+
'Water Detector': LeakSensorAccessory,
|
|
555
|
+
'Meter': TemperatureSensorAccessory,
|
|
556
|
+
'MeterPlus': TemperatureSensorAccessory,
|
|
557
|
+
'MeterPro': TemperatureSensorAccessory,
|
|
558
|
+
'WoIOSensor': TemperatureSensorAccessory,
|
|
559
|
+
'Air Purifier PM2.5': HumiditySensorAccessory,
|
|
560
|
+
'Air Purifier Table PM2.5': HumiditySensorAccessory,
|
|
561
|
+
'Air Purifier': HumiditySensorAccessory,
|
|
562
|
+
'Air Purifier VOC': HumiditySensorAccessory,
|
|
563
|
+
'Air Purifier Table VOC': HumiditySensorAccessory,
|
|
564
|
+
// Fans
|
|
565
|
+
'Battery Circulator Fan': FanAccessory,
|
|
566
|
+
// Curtains / Blinds
|
|
567
|
+
'Blind Tilt': VenetianBlindAccessory,
|
|
568
|
+
'Curtain': WindowBlindAccessory,
|
|
569
|
+
'Curtain2': WindowBlindAccessory,
|
|
570
|
+
'Curtain3': WindowBlindAccessory,
|
|
571
|
+
'Curtain 2': WindowBlindAccessory,
|
|
572
|
+
'WoRollerShade': WindowBlindAccessory,
|
|
573
|
+
'Roller Shade': WindowBlindAccessory,
|
|
574
|
+
'Venetian Blind': VenetianBlindAccessory,
|
|
575
|
+
// Switches / Relays
|
|
576
|
+
'Relay Switch 1': OnOffSwitchAccessory,
|
|
577
|
+
'Relay Switch 1PM': OnOffSwitchAccessory,
|
|
578
|
+
'Relay Switch 2': OnOffSwitchAccessory,
|
|
579
|
+
'Relay Switch 3': OnOffSwitchAccessory,
|
|
580
|
+
// Misc / hubs / other
|
|
581
|
+
'Hub 2': undefined,
|
|
582
|
+
'Hub 3': undefined,
|
|
583
|
+
'Hub Mini': undefined,
|
|
584
|
+
'Bot': OnOffSwitchAccessory,
|
|
585
|
+
'Smart Bot': OnOffSwitchAccessory,
|
|
586
|
+
'Humidifier': HumiditySensorAccessory,
|
|
587
|
+
'Humidifier2': HumiditySensorAccessory,
|
|
588
|
+
'Thermostat': ThermostatAccessory,
|
|
589
|
+
'Water Heater': ThermostatAccessory,
|
|
590
|
+
};
|
|
591
|
+
const Ctor = mapping[dev.deviceType ?? ''];
|
|
592
|
+
if (!Ctor) {
|
|
593
|
+
this.debugLog(`No Matter mapping for deviceType='${dev.deviceType}', deviceId=${dev.deviceId}`);
|
|
594
|
+
return undefined;
|
|
595
|
+
}
|
|
596
|
+
// Build opts and handlers tailored for basic capabilities
|
|
597
|
+
const uuid = baseOpts.uuid;
|
|
598
|
+
const handlers = {};
|
|
599
|
+
// Choose connection type for this device (BLE vs OpenAPI)
|
|
600
|
+
const connectionType = this.chooseConnectionType(dev);
|
|
601
|
+
// On/Off common
|
|
602
|
+
handlers.onOff = makeOnOffHandlers(uuid, connectionType).onOff;
|
|
603
|
+
// If this is a light, add brightness and color handlers
|
|
604
|
+
if (['Color Bulb', 'Ceiling Light', 'Ceiling Light Pro', 'Strip Light', 'Dimmable Light'].includes(dev.deviceType ?? '')) {
|
|
605
|
+
// levelControl
|
|
606
|
+
handlers.levelControl = {
|
|
607
|
+
moveToLevelWithOnOff: async (request) => {
|
|
608
|
+
try {
|
|
609
|
+
const level = request.level;
|
|
610
|
+
const percent = Math.round((level / 254) * 100);
|
|
611
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
612
|
+
await sendBLE('setBrightness', percent);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
await sendOpenAPI('setBrightness', `${percent}`);
|
|
616
|
+
}
|
|
617
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
this.errorLog(`Failed to set brightness for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
// colorControl
|
|
625
|
+
handlers.colorControl = {
|
|
626
|
+
moveToHueAndSaturationLogic: async (request) => {
|
|
627
|
+
try {
|
|
628
|
+
const hue = request.hue;
|
|
629
|
+
const saturation = request.saturation;
|
|
630
|
+
const [r, g, b] = hs2rgb(Math.round((hue / 254) * 360), Math.round((saturation / 254) * 100));
|
|
631
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
632
|
+
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
await sendOpenAPI('setColor', `${r}:${g}:${b}`);
|
|
636
|
+
}
|
|
637
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: saturation });
|
|
638
|
+
}
|
|
639
|
+
catch (e) {
|
|
640
|
+
this.errorLog(`Failed to set hue/sat for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
moveToColorLogic: async (request) => {
|
|
644
|
+
try {
|
|
645
|
+
// MoveToColor gives colorX/colorY values; convert to approximate RGB by mapping to 0-255 scale
|
|
646
|
+
const colorX = request.colorX;
|
|
647
|
+
const colorY = request.colorY;
|
|
648
|
+
// Naive conversion: map X/Y into RGB via hue approximation (not colorimetrically accurate)
|
|
649
|
+
const hueApprox = Math.round((colorX / 65535) * 360);
|
|
650
|
+
const satApprox = Math.round((colorY / 65535) * 100);
|
|
651
|
+
const [r, g, b] = hs2rgb(hueApprox, satApprox);
|
|
652
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
653
|
+
await sendBLE('setRGB', Number(request.level ?? 100), r, g, b);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
await sendOpenAPI('setColor', `${r}:${g}:${b}`);
|
|
657
|
+
}
|
|
658
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: colorX, currentY: colorY });
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
this.errorLog(`Failed to set XY color for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
// color temperature — map to kelvin and send setColorTemperature
|
|
666
|
+
handlers.colorTemperature = {
|
|
667
|
+
moveToColorTemperature: async (request) => {
|
|
668
|
+
try {
|
|
669
|
+
const kelvin = Math.round(1000000 / Number(request.colorTemperature));
|
|
670
|
+
if (connectionType === 'BLE' && this.switchBotBLE) {
|
|
671
|
+
await sendBLE('setColorTemperature', kelvin);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
await sendOpenAPI('setColorTemperature', `${kelvin}`);
|
|
675
|
+
}
|
|
676
|
+
await this.api.matter.updateAccessoryState(uuid, this.api.matter.clusterNames.ColorControl, { currentX: request.colorX ?? 0, currentY: request.colorY ?? 0 });
|
|
677
|
+
}
|
|
678
|
+
catch (e) {
|
|
679
|
+
this.errorLog(`Failed to set color temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
// Expose platform helpers to the accessory via context so accessory
|
|
685
|
+
// classes can call OpenAPI/BLE actions (sendOpenAPI/sendBLE) and know
|
|
686
|
+
// the effective connection type.
|
|
687
|
+
try {
|
|
688
|
+
/* Inject platform helpers (OpenAPI/BLE senders + logging helpers + connection type)
|
|
689
|
+
into the accessory context so Matter accessory classes can use them without
|
|
690
|
+
reaching into the platform implementation directly. */
|
|
691
|
+
;
|
|
692
|
+
baseOpts.context = Object.assign({}, baseOpts.context, {
|
|
693
|
+
sendOpenAPI,
|
|
694
|
+
sendBLE,
|
|
695
|
+
connectionType,
|
|
696
|
+
// Expose platform logging helpers so accessories can use consistent logging
|
|
697
|
+
infoLog: this.infoLog,
|
|
698
|
+
debugLog: this.debugLog,
|
|
699
|
+
warnLog: this.warnLog,
|
|
700
|
+
errorLog: this.errorLog,
|
|
701
|
+
successLog: this.successLog,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
this.debugLog('Failed to attach platform helpers to baseOpts.context: %s', e?.message ?? e);
|
|
706
|
+
}
|
|
707
|
+
const opts = Object.assign({}, baseOpts, { handlers });
|
|
708
|
+
// Instantiate the device class and return its serialized accessory
|
|
709
|
+
const instance = new Ctor(this.api, this.log, opts);
|
|
710
|
+
// Save instance in registry so platform can call device-specific update methods if needed
|
|
711
|
+
try {
|
|
712
|
+
if (dev?.deviceId) {
|
|
713
|
+
this.accessoryInstances.set(this.normalizeDeviceId(dev.deviceId), instance);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
this.debugLog('Failed to register accessory instance: %s', e?.message ?? e);
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
this.infoLog(`Initialized Matter accessory: ${displayName} (type=${dev.deviceType ?? 'Unknown'}) id=${dev.deviceId}`);
|
|
721
|
+
}
|
|
722
|
+
catch (e) {
|
|
723
|
+
this.debugLog('Failed to log initialized accessory:', e?.message ?? e);
|
|
724
|
+
}
|
|
725
|
+
// Register BLE->Matter push handler for this device's MAC (if BLE scanning is active)
|
|
726
|
+
try {
|
|
727
|
+
const mac = formatDeviceIdAsMac(dev.deviceId).toLowerCase();
|
|
728
|
+
// Handler receives advertisement/serviceData when BLE scan events arrive
|
|
729
|
+
this.bleEventHandler[mac] = async (serviceData) => {
|
|
730
|
+
const uuidLocal = baseOpts.uuid;
|
|
731
|
+
// First try model-specific / normalized parsing of BLE advertisement
|
|
732
|
+
try {
|
|
733
|
+
const parsed = this.parseAdvertisementForDevice(dev, serviceData);
|
|
734
|
+
try {
|
|
735
|
+
const _p = JSON.stringify(parsed);
|
|
736
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: ${_p}`);
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
this.debugLog(`BLE advertisement parsed for ${dev.deviceId}: [unstringifiable parse result]`);
|
|
740
|
+
}
|
|
741
|
+
if (parsed) {
|
|
742
|
+
// Power
|
|
743
|
+
if (parsed.power !== undefined) {
|
|
744
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.OnOff, { onOff: Boolean(parsed.power) });
|
|
745
|
+
}
|
|
746
|
+
// Brightness
|
|
747
|
+
if (parsed.brightness !== undefined) {
|
|
748
|
+
const rawLevel = Math.round((Number(parsed.brightness) / 100) * 254);
|
|
749
|
+
const level = Math.max(0, Math.min(254, rawLevel));
|
|
750
|
+
this.debugLog(`[BLE Brightness Debug] Device ${dev.deviceId}: rawBrightness=${parsed.brightness}, calculated=${rawLevel}, clamped=${level}`);
|
|
751
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.LevelControl, { currentLevel: level });
|
|
752
|
+
}
|
|
753
|
+
// Color
|
|
754
|
+
if (parsed.color !== undefined) {
|
|
755
|
+
const { r, g, b } = parsed.color;
|
|
756
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
757
|
+
const rawHue = Math.round((h / 360) * 254);
|
|
758
|
+
const rawSat = Math.round((s / 100) * 254);
|
|
759
|
+
const hue = Math.max(0, Math.min(254, rawHue));
|
|
760
|
+
const sat = Math.max(0, Math.min(254, rawSat));
|
|
761
|
+
this.debugLog(`[BLE Color Debug] Device ${dev.deviceId}: RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${rawHue},${rawSat}], clamped=[${hue},${sat}]`);
|
|
762
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.ColorControl, { currentHue: hue, currentSaturation: sat });
|
|
763
|
+
}
|
|
764
|
+
// Battery -> powerSource cluster (common mapping)
|
|
765
|
+
if (parsed.battery !== undefined) {
|
|
766
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
767
|
+
const deviceType = String(dev?.deviceType ?? '');
|
|
768
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt'];
|
|
769
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
770
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping BLE battery update`);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
try {
|
|
774
|
+
const percentage = Number(parsed.battery);
|
|
775
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
776
|
+
let batChargeLevel = 0;
|
|
777
|
+
if (percentage < 20) {
|
|
778
|
+
batChargeLevel = 2;
|
|
779
|
+
}
|
|
780
|
+
else if (percentage < 40) {
|
|
781
|
+
batChargeLevel = 1;
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'powerSource', { batPercentRemaining, batChargeLevel });
|
|
785
|
+
}
|
|
786
|
+
catch (updateError) {
|
|
787
|
+
// Silently skip if powerSource cluster doesn't exist on this device
|
|
788
|
+
const msg = String(updateError?.message ?? updateError);
|
|
789
|
+
if (!msg.includes('does not exist') && !msg.includes('not found')) {
|
|
790
|
+
throw updateError;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
this.debugLog(`Failed to update battery state for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// Temperature -> temperatureMeasurement
|
|
800
|
+
if (parsed.temperature !== undefined) {
|
|
801
|
+
try {
|
|
802
|
+
const c = Number(parsed.temperature);
|
|
803
|
+
const measured = Math.round(c * 100);
|
|
804
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'temperatureMeasurement', { measuredValue: measured });
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
this.debugLog(`Failed to update temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// Humidity -> relativeHumidityMeasurement
|
|
811
|
+
if (parsed.humidity !== undefined) {
|
|
812
|
+
try {
|
|
813
|
+
const percent = Number(parsed.humidity);
|
|
814
|
+
const measured = Math.round(percent * 100);
|
|
815
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'relativeHumidityMeasurement', { measuredValue: measured });
|
|
816
|
+
}
|
|
817
|
+
catch (e) {
|
|
818
|
+
this.debugLog(`Failed to update humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// Contact / Leak -> BooleanState
|
|
822
|
+
if (parsed.contact !== undefined || parsed.leak !== undefined) {
|
|
823
|
+
try {
|
|
824
|
+
// Some devices report contact as true=open; ContactSensor expects inverted value
|
|
825
|
+
const isContactOpen = parsed.contact === undefined ? undefined : Boolean(parsed.contact);
|
|
826
|
+
const leakDetected = parsed.leak === undefined ? undefined : Boolean(parsed.leak);
|
|
827
|
+
if (isContactOpen !== undefined) {
|
|
828
|
+
// If this is a contact sensor device type, invert; otherwise set conservatively
|
|
829
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
830
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: !isContactOpen });
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: isContactOpen });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (leakDetected !== undefined) {
|
|
837
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.BooleanState, { stateValue: leakDetected });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
this.debugLog(`Failed to update contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Motion -> occupancy
|
|
845
|
+
if (parsed.motion !== undefined) {
|
|
846
|
+
try {
|
|
847
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'occupancySensing', { occupancy: { occupied: Boolean(parsed.motion) } });
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
this.debugLog(`Failed to update occupancy for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Lock state -> doorLock
|
|
854
|
+
if (parsed.lock !== undefined) {
|
|
855
|
+
try {
|
|
856
|
+
const s = String(parsed.lock).toLowerCase();
|
|
857
|
+
let lockState = 0;
|
|
858
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
859
|
+
lockState = 1;
|
|
860
|
+
}
|
|
861
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
862
|
+
lockState = 2;
|
|
863
|
+
}
|
|
864
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.DoorLock, { lockState });
|
|
865
|
+
}
|
|
866
|
+
catch (e) {
|
|
867
|
+
this.debugLog(`Failed to update lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// Position / Cover -> WindowCovering (convert open percent to closed*100)
|
|
871
|
+
if (parsed.position !== undefined) {
|
|
872
|
+
try {
|
|
873
|
+
const openPercent = Number(parsed.position);
|
|
874
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
875
|
+
const value = Math.round(closedPercent * 100);
|
|
876
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value });
|
|
877
|
+
}
|
|
878
|
+
catch (e) {
|
|
879
|
+
this.debugLog(`Failed to update cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// Fan speed -> FanControl
|
|
883
|
+
if (parsed.fanSpeed !== undefined) {
|
|
884
|
+
try {
|
|
885
|
+
const percent = Number(parsed.fanSpeed);
|
|
886
|
+
await this.api.matter.updateAccessoryState(uuidLocal, this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent });
|
|
887
|
+
}
|
|
888
|
+
catch (e) {
|
|
889
|
+
this.debugLog(`Failed to update fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// Robot vacuum fields (battery already handled) - update run/operational/clean modes when present
|
|
893
|
+
if (parsed.rvcRunMode !== undefined) {
|
|
894
|
+
try {
|
|
895
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcRunMode', { currentMode: Number(parsed.rvcRunMode) });
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
this.debugLog(`Failed to update rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (parsed.rvcOperationalState !== undefined) {
|
|
902
|
+
try {
|
|
903
|
+
await this.api.matter.updateAccessoryState(uuidLocal, 'rvcOperationalState', { operationalState: Number(parsed.rvcOperationalState) });
|
|
904
|
+
}
|
|
905
|
+
catch (e) {
|
|
906
|
+
this.debugLog(`Failed to update rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// If we parsed something from serviceData prefer it and return early
|
|
910
|
+
if (serviceData) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
catch (e) {
|
|
916
|
+
this.debugLog(`BLE advertisement parsing failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
917
|
+
}
|
|
918
|
+
// Fallback to OpenAPI getDeviceStatus when serviceData is not present or parsing failed
|
|
919
|
+
if (!this.switchBotAPI) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
try {
|
|
923
|
+
this.apiTracker?.track();
|
|
924
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
925
|
+
const respAny = response;
|
|
926
|
+
const body = respAny?.body ?? respAny;
|
|
927
|
+
try {
|
|
928
|
+
const s = JSON.stringify(body);
|
|
929
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
930
|
+
}
|
|
931
|
+
catch (e) {
|
|
932
|
+
this.debugLog(`OpenAPI getDeviceStatus for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
933
|
+
}
|
|
934
|
+
if (!(statusCode === 100 || statusCode === 200)) {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const status = body?.status ?? body;
|
|
938
|
+
// Use centralized mapper which prefers accessory instance update helpers
|
|
939
|
+
await this.applyStatusToAccessory(uuidLocal, dev, status);
|
|
940
|
+
}
|
|
941
|
+
catch (e) {
|
|
942
|
+
this.debugLog(`BLE push handler failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
catch (e) {
|
|
947
|
+
this.debugLog(`Failed to register BLE handler for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
948
|
+
}
|
|
949
|
+
// Schedule periodic OpenAPI refreshes for this device (if OpenAPI is configured)
|
|
950
|
+
try {
|
|
951
|
+
const nid = this.normalizeDeviceId(dev.deviceId);
|
|
952
|
+
// Clear any existing timer for this device
|
|
953
|
+
const existing = this.refreshTimers.get(nid);
|
|
954
|
+
if (existing) {
|
|
955
|
+
clearInterval(existing);
|
|
956
|
+
this.refreshTimers.delete(nid);
|
|
957
|
+
}
|
|
958
|
+
const platformInterval = this.getPlatformBatchInterval();
|
|
959
|
+
const hasDeviceInterval = typeof dev.refreshRate === 'number' && Number(dev.refreshRate) > 0;
|
|
960
|
+
if (this.switchBotAPI && (hasDeviceInterval || platformInterval > 0)) {
|
|
961
|
+
// Immediate one-shot to populate initial state
|
|
962
|
+
;
|
|
963
|
+
(async () => {
|
|
964
|
+
try {
|
|
965
|
+
this.infoLog(`Performing initial OpenAPI refresh for ${dev.deviceId}`);
|
|
966
|
+
this.apiTracker?.track();
|
|
967
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(dev.deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
968
|
+
const respAny = response;
|
|
969
|
+
const body = respAny?.body ?? respAny;
|
|
970
|
+
try {
|
|
971
|
+
const s = JSON.stringify(body);
|
|
972
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} body=${s}`);
|
|
973
|
+
}
|
|
974
|
+
catch (e) {
|
|
975
|
+
this.debugLog(`Initial OpenAPI refresh for ${dev.deviceId} returned statusCode=${statusCode} (body not stringifiable)`);
|
|
976
|
+
}
|
|
977
|
+
if (statusCode === 100 || statusCode === 200) {
|
|
978
|
+
const status = body?.status ?? body;
|
|
979
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
980
|
+
this.infoLog(`Initial OpenAPI refresh succeeded for ${dev.deviceId}`);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
this.warnLog(`Initial OpenAPI refresh returned unexpected statusCode=${statusCode} for ${dev.deviceId}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch (e) {
|
|
987
|
+
this.errorLog(`Initial OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
988
|
+
}
|
|
989
|
+
})();
|
|
990
|
+
if (hasDeviceInterval) {
|
|
991
|
+
// Create a per-device timer and exclude it from batch
|
|
992
|
+
const interval = Number(dev.refreshRate);
|
|
993
|
+
this.perDeviceRefreshSet.add(nid);
|
|
994
|
+
const timer = setInterval(async () => {
|
|
995
|
+
try {
|
|
996
|
+
// Skip if device is under cooldown
|
|
997
|
+
const now = Date.now();
|
|
998
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
999
|
+
if (now < nextAllowed) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
await this.refreshSingleDeviceWithRetry(dev);
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
this.debugLog(`Per-device refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1006
|
+
}
|
|
1007
|
+
}, interval * 1000);
|
|
1008
|
+
this.refreshTimers.set(nid, timer);
|
|
1009
|
+
this.infoLog(`Started per-device refresh timer for ${dev.deviceId} at ${interval}s`);
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// Start platform-level batched refresh timer (only once)
|
|
1013
|
+
this.startPlatformRefreshTimer(platformInterval);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch (e) {
|
|
1018
|
+
this.debugLog(`Failed to schedule refresh for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1019
|
+
}
|
|
1020
|
+
return instance.toAccessory();
|
|
91
1021
|
}
|
|
92
1022
|
/**
|
|
93
1023
|
* Discover devices via SwitchBot OpenAPI and cache them for later use
|
|
94
1024
|
*/
|
|
95
1025
|
async discoverDevices() {
|
|
96
1026
|
if (!this.switchBotAPI) {
|
|
97
|
-
this.
|
|
1027
|
+
this.debugLog('SwitchBot OpenAPI not configured; skipping discovery');
|
|
98
1028
|
return;
|
|
99
1029
|
}
|
|
100
1030
|
try {
|
|
1031
|
+
this.apiTracker?.track();
|
|
101
1032
|
const { response, statusCode } = await this.switchBotAPI.getDevices();
|
|
102
|
-
this.
|
|
1033
|
+
this.debugLog(`SwitchBot getDevices response status: ${statusCode}`);
|
|
103
1034
|
if (statusCode === 100 || statusCode === 200) {
|
|
104
1035
|
const deviceList = Array.isArray(response?.body?.deviceList) ? response.body.deviceList : [];
|
|
105
1036
|
this.discoveredDevices = deviceList;
|
|
106
|
-
this.
|
|
1037
|
+
this.infoLog(`Discovered ${deviceList.length} SwitchBot device(s) from OpenAPI`);
|
|
107
1038
|
for (const d of deviceList) {
|
|
108
|
-
this.
|
|
1039
|
+
this.debugLog(` - ${d.deviceName} (${d.deviceType}) id=${d.deviceId}`);
|
|
109
1040
|
}
|
|
110
1041
|
}
|
|
111
1042
|
else {
|
|
112
|
-
this.
|
|
1043
|
+
this.warnLog(`SwitchBot getDevices returned status ${statusCode}`);
|
|
113
1044
|
}
|
|
114
1045
|
}
|
|
115
1046
|
catch (e) {
|
|
116
|
-
this.
|
|
1047
|
+
this.errorLog('Failed to discover SwitchBot devices:', e?.message ?? e);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Setup MQTT connection (if configured) and route incoming webhook messages
|
|
1052
|
+
* to registered webhook handlers. Mirrors behaviour in platform-hap.
|
|
1053
|
+
*/
|
|
1054
|
+
async setupMqtt() {
|
|
1055
|
+
if (this.config.options?.mqttURL) {
|
|
1056
|
+
try {
|
|
1057
|
+
const { connectAsync } = asyncmqtt;
|
|
1058
|
+
this.mqttClient = await connectAsync(this.config.options?.mqttURL, this.config.options.mqttOptions || {});
|
|
1059
|
+
this.debugLog('MQTT connection has been established successfully.');
|
|
1060
|
+
this.mqttClient.on('error', async (e) => {
|
|
1061
|
+
this.errorLog(`Failed to publish MQTT messages. ${e.message ?? e}`);
|
|
1062
|
+
});
|
|
1063
|
+
if (!this.config.options?.webhookURL) {
|
|
1064
|
+
// receive webhook events via MQTT
|
|
1065
|
+
this.infoLog(`Webhook is configured to be received through ${this.config.options.mqttURL}/homebridge-switchbot/webhook.`);
|
|
1066
|
+
this.mqttClient.subscribe('homebridge-switchbot/webhook/+');
|
|
1067
|
+
this.mqttClient.on('message', async (topic, message) => {
|
|
1068
|
+
try {
|
|
1069
|
+
this.debugLog(`Received Webhook via MQTT: ${topic}=${message}`);
|
|
1070
|
+
const context = JSON.parse(message.toString());
|
|
1071
|
+
this.webhookEventHandler[context.deviceMac]?.(context);
|
|
1072
|
+
}
|
|
1073
|
+
catch (e) {
|
|
1074
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (e) {
|
|
1080
|
+
this.mqttClient = null;
|
|
1081
|
+
this.errorLog(`Failed to establish MQTT connection. ${e.message ?? e}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Setup OpenAPI webhook (if webhookURL configured) and forward incoming
|
|
1087
|
+
* webhook events to MQTT (if configured) and local handlers.
|
|
1088
|
+
*/
|
|
1089
|
+
async setupwebhook() {
|
|
1090
|
+
if (this.config.options?.webhookURL) {
|
|
1091
|
+
const url = this.config.options?.webhookURL;
|
|
1092
|
+
try {
|
|
1093
|
+
this.switchBotAPI?.setupWebhook(url);
|
|
1094
|
+
this.infoLog(`Webhook configured for URL: ${url}`);
|
|
1095
|
+
// Listen for webhook events
|
|
1096
|
+
this.switchBotAPI?.on('webhookEvent', (body) => {
|
|
1097
|
+
try {
|
|
1098
|
+
this.infoLog(`Received webhook event for device: ${body.context.deviceMac}`);
|
|
1099
|
+
if (this.config.options?.mqttURL) {
|
|
1100
|
+
const mac = body.context.deviceMac?.toLowerCase().match(/[\s\S]{1,2}/g)?.join(':');
|
|
1101
|
+
const options = this.config.options?.mqttPubOptions || {};
|
|
1102
|
+
this.mqttClient?.publish(`homebridge-switchbot/webhook/${mac}`, `${JSON.stringify(body.context)}`, options);
|
|
1103
|
+
}
|
|
1104
|
+
this.webhookEventHandler[body.context.deviceMac]?.(body.context);
|
|
1105
|
+
}
|
|
1106
|
+
catch (e) {
|
|
1107
|
+
this.errorLog(`Failed to handle webhook event. Error: ${e.message ?? e}`);
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
catch (e) {
|
|
1112
|
+
this.errorLog(`Failed to setup webhook. Error: ${e.message ?? e}`);
|
|
1113
|
+
}
|
|
1114
|
+
this.api.on('shutdown', async () => {
|
|
1115
|
+
try {
|
|
1116
|
+
this.switchBotAPI?.deleteWebhook(url);
|
|
1117
|
+
}
|
|
1118
|
+
catch (e) {
|
|
1119
|
+
this.errorLog(`Failed to delete webhook. Error: ${e.message ?? e}`);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
117
1122
|
}
|
|
118
1123
|
}
|
|
119
1124
|
/**
|
|
@@ -126,17 +1131,504 @@ export class SwitchBotMatterPlatform {
|
|
|
126
1131
|
if (!this.switchBotAPI) {
|
|
127
1132
|
throw new Error('SwitchBot OpenAPI not initialized');
|
|
128
1133
|
}
|
|
1134
|
+
this.apiTracker?.track();
|
|
129
1135
|
const { response, statusCode } = await this.switchBotAPI.controlDevice(deviceObj.deviceId, bodyChange.command, bodyChange.parameter, bodyChange.commandType, this.config.credentials?.token, this.config.credentials?.secret);
|
|
130
1136
|
return { response, statusCode };
|
|
131
1137
|
}
|
|
132
1138
|
catch (e) {
|
|
133
|
-
this.
|
|
1139
|
+
this.debugLog(`retryCommand error: ${e?.message ?? e}`);
|
|
134
1140
|
}
|
|
135
1141
|
retryCount++;
|
|
136
1142
|
await sleep(delayBetweenRetries);
|
|
137
1143
|
}
|
|
138
1144
|
return { response: {}, statusCode: 500 };
|
|
139
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Parse BLE advertisement/serviceData into normalized fields for a given device.
|
|
1148
|
+
* Returns null when serviceData is falsy or parsing fails.
|
|
1149
|
+
*/
|
|
1150
|
+
parseAdvertisementForDevice(dev, serviceData) {
|
|
1151
|
+
if (!serviceData) {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
try {
|
|
1155
|
+
const sd = serviceData;
|
|
1156
|
+
const result = {};
|
|
1157
|
+
// Power/on state - supports multiple field names used by different models
|
|
1158
|
+
const power = sd.power ?? sd.on ?? sd.p;
|
|
1159
|
+
if (power !== undefined) {
|
|
1160
|
+
result.power = (String(power).toLowerCase() === 'on' || Number(power) === 1);
|
|
1161
|
+
}
|
|
1162
|
+
// Brightness (0-100)
|
|
1163
|
+
const brightness = sd.brightness ?? sd.b;
|
|
1164
|
+
if (brightness !== undefined) {
|
|
1165
|
+
result.brightness = Number(brightness);
|
|
1166
|
+
}
|
|
1167
|
+
// Color - could be 'r:g:b', '#rrggbb' or 'rrggbb'
|
|
1168
|
+
const color = sd.color ?? sd.rgb ?? sd.c;
|
|
1169
|
+
if (color !== undefined) {
|
|
1170
|
+
let r = 0;
|
|
1171
|
+
let g = 0;
|
|
1172
|
+
let b = 0;
|
|
1173
|
+
const c = String(color);
|
|
1174
|
+
if (c.includes(':')) {
|
|
1175
|
+
const parts = c.split(':').map(Number);
|
|
1176
|
+
[r, g, b] = parts;
|
|
1177
|
+
}
|
|
1178
|
+
else if (c.includes(',')) {
|
|
1179
|
+
const parts = c.split(',').map(s => Number(s.trim()));
|
|
1180
|
+
[r, g, b] = parts;
|
|
1181
|
+
}
|
|
1182
|
+
else if (c.includes(' ')) {
|
|
1183
|
+
const parts = c.split(' ').map(s => Number(s.trim()));
|
|
1184
|
+
[r, g, b] = parts;
|
|
1185
|
+
}
|
|
1186
|
+
else if (c.startsWith('#')) {
|
|
1187
|
+
const hex = c.replace('#', '');
|
|
1188
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
1189
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
1190
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
1191
|
+
}
|
|
1192
|
+
else if (/^[0-9a-f]{6}$/i.test(c)) {
|
|
1193
|
+
r = Number.parseInt(c.substring(0, 2), 16);
|
|
1194
|
+
g = Number.parseInt(c.substring(2, 4), 16);
|
|
1195
|
+
b = Number.parseInt(c.substring(4, 6), 16);
|
|
1196
|
+
}
|
|
1197
|
+
result.color = { r, g, b };
|
|
1198
|
+
}
|
|
1199
|
+
// Battery (some devices use battery or batt)
|
|
1200
|
+
const battery = sd.battery ?? sd.batt;
|
|
1201
|
+
if (battery !== undefined) {
|
|
1202
|
+
result.battery = Number(battery);
|
|
1203
|
+
}
|
|
1204
|
+
// VOC / TVOC (some air quality devices report total volatile organic compounds)
|
|
1205
|
+
const voc = sd.voc ?? sd.tvoc;
|
|
1206
|
+
if (voc !== undefined) {
|
|
1207
|
+
result.voc = Number(voc);
|
|
1208
|
+
}
|
|
1209
|
+
// PM10 (some devices report PM10 alongside PM2.5)
|
|
1210
|
+
const pm10 = sd.pm10;
|
|
1211
|
+
if (pm10 !== undefined) {
|
|
1212
|
+
result.pm10 = Number(pm10);
|
|
1213
|
+
}
|
|
1214
|
+
// PM2.5 (some BLE adverts use pm25 / pm_2_5)
|
|
1215
|
+
const pm25 = sd.pm2_5 ?? sd.pm25 ?? sd.pm_2_5;
|
|
1216
|
+
if (pm25 !== undefined) {
|
|
1217
|
+
result.pm25 = Number(pm25);
|
|
1218
|
+
}
|
|
1219
|
+
// CO2 (carbon dioxide ppm)
|
|
1220
|
+
const co2 = sd.co2 ?? sd.co2ppm ?? sd.carbonDioxide;
|
|
1221
|
+
if (co2 !== undefined) {
|
|
1222
|
+
result.co2 = Number(co2);
|
|
1223
|
+
}
|
|
1224
|
+
// Temperature (C) and Humidity (%) — support common shorthand keys
|
|
1225
|
+
const temperature = sd.temperature ?? sd.temp ?? sd.t;
|
|
1226
|
+
if (temperature !== undefined) {
|
|
1227
|
+
result.temperature = Number(temperature);
|
|
1228
|
+
}
|
|
1229
|
+
const humidity = sd.humidity ?? sd.h ?? sd.humid;
|
|
1230
|
+
if (humidity !== undefined) {
|
|
1231
|
+
result.humidity = Number(humidity);
|
|
1232
|
+
}
|
|
1233
|
+
// Motion, Contact, Leak
|
|
1234
|
+
const motion = sd.motion ?? sd.m;
|
|
1235
|
+
if (motion !== undefined) {
|
|
1236
|
+
result.motion = Boolean(motion);
|
|
1237
|
+
}
|
|
1238
|
+
const contact = sd.contact ?? sd.open;
|
|
1239
|
+
if (contact !== undefined) {
|
|
1240
|
+
result.contact = contact;
|
|
1241
|
+
}
|
|
1242
|
+
const leak = sd.leak ?? sd.water;
|
|
1243
|
+
if (leak !== undefined) {
|
|
1244
|
+
result.leak = Boolean(leak);
|
|
1245
|
+
}
|
|
1246
|
+
// Position / Cover / Curtain synonyms
|
|
1247
|
+
const position = sd.position ?? sd.percent ?? sd.slidePosition ?? sd.curtainPosition;
|
|
1248
|
+
if (position !== undefined) {
|
|
1249
|
+
result.position = Number(position);
|
|
1250
|
+
}
|
|
1251
|
+
// Fan speed/speed
|
|
1252
|
+
const fanSpeed = sd.fanSpeed ?? sd.speed;
|
|
1253
|
+
if (fanSpeed !== undefined) {
|
|
1254
|
+
result.fanSpeed = Number(fanSpeed);
|
|
1255
|
+
}
|
|
1256
|
+
// Lock state
|
|
1257
|
+
const lock = sd.lock;
|
|
1258
|
+
if (lock !== undefined) {
|
|
1259
|
+
result.lock = lock;
|
|
1260
|
+
}
|
|
1261
|
+
// Robot vacuum fields
|
|
1262
|
+
const rvcRunMode = sd.rvcRunMode;
|
|
1263
|
+
if (rvcRunMode !== undefined) {
|
|
1264
|
+
result.rvcRunMode = rvcRunMode;
|
|
1265
|
+
}
|
|
1266
|
+
const rvcOperationalState = sd.rvcOperationalState;
|
|
1267
|
+
if (rvcOperationalState !== undefined) {
|
|
1268
|
+
result.rvcOperationalState = rvcOperationalState;
|
|
1269
|
+
}
|
|
1270
|
+
return result;
|
|
1271
|
+
}
|
|
1272
|
+
catch (e) {
|
|
1273
|
+
this.debugLog(`parseAdvertisementForDevice failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Central helper to apply a SwitchBot status object to a Matter accessory.
|
|
1279
|
+
* Tries to call accessory instance update helpers when available, otherwise
|
|
1280
|
+
* falls back to calling api.matter.updateAccessoryState directly.
|
|
1281
|
+
*/
|
|
1282
|
+
async applyStatusToAccessory(uuidLocal, dev, status) {
|
|
1283
|
+
if (!status) {
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const instance = dev?.deviceId ? this.accessoryInstances.get(this.normalizeDeviceId(dev.deviceId)) : undefined;
|
|
1287
|
+
// Helper to safely call instance methods or fallback to api.matter.updateAccessoryState
|
|
1288
|
+
const safeUpdate = async (cluster, attributes, methodName) => {
|
|
1289
|
+
try {
|
|
1290
|
+
// Special-case: powerSource cluster is optional on many devices (e.g., Curtains/Blinds).
|
|
1291
|
+
// To avoid noisy Matter server errors ("Behavior ID powerSource does not exist"),
|
|
1292
|
+
// always use the direct updateAccessoryState path wrapped in a guard for this cluster,
|
|
1293
|
+
// even when an accessory instance is present.
|
|
1294
|
+
const powerClusterName = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource)
|
|
1295
|
+
? this.api.matter.clusterNames.PowerSource
|
|
1296
|
+
: 'powerSource';
|
|
1297
|
+
const isPowerSourceCluster = cluster === powerClusterName || cluster === 'powerSource';
|
|
1298
|
+
// If the accessory instance declares supported clusters, skip updates for clusters
|
|
1299
|
+
// not present to avoid triggering Matter server errors and logs.
|
|
1300
|
+
let clusterSupported = true;
|
|
1301
|
+
if (instance && instance.clusters) {
|
|
1302
|
+
const declared = instance.clusters;
|
|
1303
|
+
if (Array.isArray(declared)) {
|
|
1304
|
+
clusterSupported = declared.includes(cluster);
|
|
1305
|
+
}
|
|
1306
|
+
else if (typeof declared === 'object') {
|
|
1307
|
+
clusterSupported = Object.prototype.hasOwnProperty.call(declared, cluster);
|
|
1308
|
+
}
|
|
1309
|
+
if (!clusterSupported) {
|
|
1310
|
+
this.debugLog(`Cluster ${cluster} not declared on accessory for ${dev.deviceId}, skipping update`);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (instance && methodName && typeof instance[methodName] === 'function') {
|
|
1315
|
+
// prefer device-specific update helpers when available
|
|
1316
|
+
await instance[methodName](...(Object.values(attributes)));
|
|
1317
|
+
}
|
|
1318
|
+
else if (!isPowerSourceCluster && instance && typeof instance.updateState === 'function') {
|
|
1319
|
+
// some accessories expose updateState that accepts cluster and attributes
|
|
1320
|
+
await instance.updateState(cluster, attributes);
|
|
1321
|
+
}
|
|
1322
|
+
else {
|
|
1323
|
+
try {
|
|
1324
|
+
await this.api.matter.updateAccessoryState(uuidLocal, cluster, attributes);
|
|
1325
|
+
}
|
|
1326
|
+
catch (updateError) {
|
|
1327
|
+
// Silently ignore "does not exist" errors for clusters that aren't
|
|
1328
|
+
// supported by this device type (e.g., powerSource on WindowBlind).
|
|
1329
|
+
const msg = String(updateError?.message ?? updateError);
|
|
1330
|
+
if (msg.includes('does not exist') || msg.includes('not found')) {
|
|
1331
|
+
this.debugLog(`Cluster ${cluster} not available on ${dev.deviceId}, skipping update`);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
throw updateError;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch (e) {
|
|
1339
|
+
this.debugLog(`safeUpdate failed for ${dev.deviceId} cluster=${cluster}: ${e?.message ?? e}`);
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
try {
|
|
1343
|
+
// On/Off
|
|
1344
|
+
if (status?.power !== undefined) {
|
|
1345
|
+
const on = (String(status.power).toLowerCase() === 'on' || Number(status.power) === 1 || Boolean(status.power) === true);
|
|
1346
|
+
await safeUpdate(this.api.matter.clusterNames.OnOff, { onOff: on }, 'updateOnOffState');
|
|
1347
|
+
}
|
|
1348
|
+
// Robot vacuum: some OpenAPI responses use 'runState' or similar textual
|
|
1349
|
+
// fields to indicate cleaning/mapping/idle. Map common textual values to
|
|
1350
|
+
// the numeric run mode values expected by the RoboticVacuumAccessory and
|
|
1351
|
+
// prefer calling accessory helpers when present.
|
|
1352
|
+
if (status?.runState !== undefined || status?.run_state !== undefined || status?.run !== undefined) {
|
|
1353
|
+
try {
|
|
1354
|
+
const raw = status?.runState ?? status?.run_state ?? status?.run;
|
|
1355
|
+
let mode;
|
|
1356
|
+
if (typeof raw === 'number') {
|
|
1357
|
+
mode = Number(raw);
|
|
1358
|
+
}
|
|
1359
|
+
else if (typeof raw === 'string') {
|
|
1360
|
+
const s = raw.toLowerCase();
|
|
1361
|
+
if (s.includes('clean')) {
|
|
1362
|
+
mode = 1; // Cleaning
|
|
1363
|
+
}
|
|
1364
|
+
else if (s.includes('map')) {
|
|
1365
|
+
mode = 2; // Mapping
|
|
1366
|
+
}
|
|
1367
|
+
else if (s.includes('idle') || s.includes('stop') || s.includes('dock') || s.includes('charge') || s.includes('docked')) {
|
|
1368
|
+
mode = 0; // Idle
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
const n = Number(raw);
|
|
1372
|
+
if (!Number.isNaN(n)) {
|
|
1373
|
+
mode = n;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (mode !== undefined) {
|
|
1378
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(mode) }, 'updateRunMode');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch (e) {
|
|
1382
|
+
this.debugLog(`Failed to apply runState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
// Brightness
|
|
1386
|
+
if (status?.brightness !== undefined) {
|
|
1387
|
+
const rawBrightness = Number(status.brightness);
|
|
1388
|
+
const clampedBrightness = Math.max(0, Math.min(100, rawBrightness));
|
|
1389
|
+
// If instance has updateBrightness method, it expects percentage (0-100)
|
|
1390
|
+
// Otherwise, updateAccessoryState expects Matter-scaled value (0-254)
|
|
1391
|
+
if (instance && typeof instance.updateBrightness === 'function') {
|
|
1392
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateBrightness with percent=${clampedBrightness}`);
|
|
1393
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedBrightness }, 'updateBrightness');
|
|
1394
|
+
}
|
|
1395
|
+
else {
|
|
1396
|
+
const level = Math.round((clampedBrightness / 100) * 254);
|
|
1397
|
+
const clampedLevel = Math.max(0, Math.min(254, level));
|
|
1398
|
+
this.debugLog(`[Brightness Debug] Device ${dev.deviceId}: calling updateAccessoryState with rawBrightness=${rawBrightness}, level=${clampedLevel}`);
|
|
1399
|
+
await safeUpdate(this.api.matter.clusterNames.LevelControl, { currentLevel: clampedLevel });
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// Color
|
|
1403
|
+
if (status?.color !== undefined) {
|
|
1404
|
+
const color = String(status.color);
|
|
1405
|
+
let r = 0;
|
|
1406
|
+
let g = 0;
|
|
1407
|
+
let b = 0;
|
|
1408
|
+
if (color.includes(':')) {
|
|
1409
|
+
const parts = color.split(':').map(Number);
|
|
1410
|
+
[r, g, b] = parts;
|
|
1411
|
+
}
|
|
1412
|
+
else if (color.includes(',')) {
|
|
1413
|
+
const parts = color.split(',').map(s => Number(s.trim()));
|
|
1414
|
+
[r, g, b] = parts;
|
|
1415
|
+
}
|
|
1416
|
+
else if (color.includes(' ')) {
|
|
1417
|
+
const parts = color.split(' ').map(s => Number(s.trim()));
|
|
1418
|
+
[r, g, b] = parts;
|
|
1419
|
+
}
|
|
1420
|
+
else if (color.startsWith('#')) {
|
|
1421
|
+
const hex = color.replace('#', '');
|
|
1422
|
+
r = Number.parseInt(hex.substring(0, 2), 16);
|
|
1423
|
+
g = Number.parseInt(hex.substring(2, 4), 16);
|
|
1424
|
+
b = Number.parseInt(hex.substring(4, 6), 16);
|
|
1425
|
+
}
|
|
1426
|
+
const [h, s] = rgb2hs(r, g, b);
|
|
1427
|
+
const clampedH = Math.max(0, Math.min(360, h));
|
|
1428
|
+
const clampedS = Math.max(0, Math.min(100, s));
|
|
1429
|
+
// If instance has updateHueSaturation method, it expects raw values (h: 0-360, s: 0-100)
|
|
1430
|
+
// Otherwise, updateAccessoryState expects Matter-scaled values (0-254)
|
|
1431
|
+
if (instance && typeof instance.updateHueSaturation === 'function') {
|
|
1432
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateHueSaturation with color="${color}", RGB=[${r},${g},${b}], HS=[${clampedH},${clampedS}]`);
|
|
1433
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedH, currentSaturation: clampedS }, 'updateHueSaturation');
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
const hue = Math.round((clampedH / 360) * 254);
|
|
1437
|
+
const sat = Math.round((clampedS / 100) * 254);
|
|
1438
|
+
const clampedHue = Math.max(0, Math.min(254, hue));
|
|
1439
|
+
const clampedSat = Math.max(0, Math.min(254, sat));
|
|
1440
|
+
this.debugLog(`[Color Debug] Device ${dev.deviceId}: calling updateAccessoryState with color="${color}", RGB=[${r},${g},${b}], HS=[${h},${s}], Matter=[${clampedHue},${clampedSat}]`);
|
|
1441
|
+
await safeUpdate(this.api.matter.clusterNames.ColorControl, { currentHue: clampedHue, currentSaturation: clampedSat });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// Battery/powerSource (support many possible field names)
|
|
1445
|
+
// Note: Some device types like WindowBlind don't support PowerSource cluster
|
|
1446
|
+
if (status?.battery !== undefined || status?.batt !== undefined || status?.batteryLevel !== undefined || status?.batteryPercentage !== undefined || status?.battery_level !== undefined) {
|
|
1447
|
+
// Skip battery updates for device types that don't support PowerSource cluster
|
|
1448
|
+
const deviceType = String(status?.deviceType ?? dev?.deviceType ?? '');
|
|
1449
|
+
const unsupportedTypes = ['Curtain', 'Curtain2', 'Curtain3', 'Curtain 2', 'Blind Tilt'];
|
|
1450
|
+
if (unsupportedTypes.includes(deviceType)) {
|
|
1451
|
+
this.debugLog(`Device ${dev.deviceId} type ${deviceType} does not support PowerSource cluster, skipping battery update`);
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
try {
|
|
1455
|
+
const percentage = Number(status?.battery ?? status?.batt ?? status?.batteryPercentage ?? status?.batteryLevel ?? status?.battery_level);
|
|
1456
|
+
const batPercentRemaining = Math.max(0, Math.min(200, Math.round(percentage * 2)));
|
|
1457
|
+
let batChargeLevel = 0;
|
|
1458
|
+
if (percentage < 20) {
|
|
1459
|
+
batChargeLevel = 2;
|
|
1460
|
+
}
|
|
1461
|
+
else if (percentage < 40) {
|
|
1462
|
+
batChargeLevel = 1;
|
|
1463
|
+
}
|
|
1464
|
+
const powerCluster = (this.api.matter?.clusterNames && this.api.matter.clusterNames.PowerSource) ? this.api.matter.clusterNames.PowerSource : 'powerSource';
|
|
1465
|
+
await safeUpdate(powerCluster, { batPercentRemaining, batChargeLevel }, 'updateBatteryPercentage');
|
|
1466
|
+
}
|
|
1467
|
+
catch (e) {
|
|
1468
|
+
this.debugLog(`Failed to apply battery status for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
// Temperature + thermostat
|
|
1473
|
+
if (status?.temperature !== undefined || status?.temp !== undefined) {
|
|
1474
|
+
try {
|
|
1475
|
+
const c = Number(status?.temperature ?? status?.temp);
|
|
1476
|
+
const measured = Math.round(c * 100);
|
|
1477
|
+
await safeUpdate('temperatureMeasurement', { measuredValue: measured }, 'updateTemperature');
|
|
1478
|
+
// Thermostat-specific mapping
|
|
1479
|
+
if (status?.targetTemp !== undefined || status?.targetTemperature !== undefined || status?.heatingSetpoint !== undefined) {
|
|
1480
|
+
const target = Number(status?.targetTemp ?? status?.targetTemperature ?? status?.heatingSetpoint);
|
|
1481
|
+
const val = Math.round(target * 100);
|
|
1482
|
+
await safeUpdate('thermostat', { occupiedHeatingSetpoint: val }, 'updateHeatingSetpoint');
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch (e) {
|
|
1486
|
+
this.debugLog(`Failed to apply temperature for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// Humidity (support different keys)
|
|
1490
|
+
if (status?.humidity !== undefined || status?.h !== undefined || status?.humid !== undefined) {
|
|
1491
|
+
try {
|
|
1492
|
+
const percent = Number(status?.humidity ?? status?.h ?? status?.humid);
|
|
1493
|
+
const measured = Math.round(percent * 100);
|
|
1494
|
+
await safeUpdate('relativeHumidityMeasurement', { measuredValue: measured }, 'updateHumidity');
|
|
1495
|
+
}
|
|
1496
|
+
catch (e) {
|
|
1497
|
+
this.debugLog(`Failed to apply humidity for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
// Contact / Leak -> BooleanState
|
|
1501
|
+
if (status?.contact !== undefined || status?.open !== undefined || status?.leak !== undefined || status?.water !== undefined) {
|
|
1502
|
+
try {
|
|
1503
|
+
const isContactOpen = status?.contact ?? status?.open;
|
|
1504
|
+
if (isContactOpen !== undefined) {
|
|
1505
|
+
if ((dev.deviceType || '').includes('Contact')) {
|
|
1506
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: !(String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: (String(isContactOpen).toLowerCase() === 'true' || Number(isContactOpen) === 1) }, 'updateContactState');
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const leakDetected = status?.leak ?? status?.water;
|
|
1513
|
+
if (leakDetected !== undefined) {
|
|
1514
|
+
await safeUpdate(this.api.matter.clusterNames.BooleanState, { stateValue: Boolean(leakDetected) }, 'updateLeakState');
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
catch (e) {
|
|
1518
|
+
this.debugLog(`Failed to apply contact/leak for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// Motion -> occupancy
|
|
1522
|
+
if (status?.motion !== undefined || status?.m !== undefined) {
|
|
1523
|
+
try {
|
|
1524
|
+
const detected = Boolean(status?.motion ?? status?.m);
|
|
1525
|
+
await safeUpdate('occupancySensing', { occupancy: { occupied: detected } }, 'updateOccupancy');
|
|
1526
|
+
}
|
|
1527
|
+
catch (e) {
|
|
1528
|
+
this.debugLog(`Failed to apply motion for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// Lock state
|
|
1532
|
+
if (status?.lock !== undefined) {
|
|
1533
|
+
try {
|
|
1534
|
+
const s = String(status.lock).toLowerCase();
|
|
1535
|
+
let lockState = 0;
|
|
1536
|
+
if (s === 'locked' || s === '1' || s === 'true') {
|
|
1537
|
+
lockState = 1;
|
|
1538
|
+
}
|
|
1539
|
+
else if (s === 'unlocked' || s === '0' || s === 'false') {
|
|
1540
|
+
lockState = 2;
|
|
1541
|
+
}
|
|
1542
|
+
await safeUpdate(this.api.matter.clusterNames.DoorLock, { lockState }, 'updateLockState');
|
|
1543
|
+
}
|
|
1544
|
+
catch (e) {
|
|
1545
|
+
this.debugLog(`Failed to apply lock for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
// Cover position
|
|
1549
|
+
if (status?.position !== undefined || status?.percent !== undefined) {
|
|
1550
|
+
try {
|
|
1551
|
+
const openPercent = Number(status?.position ?? status?.percent);
|
|
1552
|
+
const closedPercent = 100 - Math.max(0, Math.min(100, openPercent));
|
|
1553
|
+
const value = Math.round(closedPercent * 100);
|
|
1554
|
+
await safeUpdate(this.api.matter.clusterNames.WindowCovering, { currentPositionLiftPercent100ths: value, targetPositionLiftPercent100ths: value }, 'updateLiftPosition');
|
|
1555
|
+
}
|
|
1556
|
+
catch (e) {
|
|
1557
|
+
this.debugLog(`Failed to apply cover position for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// Fan
|
|
1561
|
+
if (status?.fanSpeed !== undefined || status?.speed !== undefined) {
|
|
1562
|
+
try {
|
|
1563
|
+
const percent = Number(status?.fanSpeed ?? status?.speed);
|
|
1564
|
+
await safeUpdate(this.api.matter.clusterNames.FanControl, { percentSetting: percent, percentCurrent: percent }, 'updateFanSpeed');
|
|
1565
|
+
}
|
|
1566
|
+
catch (e) {
|
|
1567
|
+
this.debugLog(`Failed to apply fan speed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Robot vacuum: run/operational/clean modes
|
|
1571
|
+
if (status?.rvcRunMode !== undefined) {
|
|
1572
|
+
try {
|
|
1573
|
+
await safeUpdate('rvcRunMode', { currentMode: Number(status.rvcRunMode) }, 'updateRunMode');
|
|
1574
|
+
}
|
|
1575
|
+
catch (e) {
|
|
1576
|
+
this.debugLog(`Failed to apply rvcRunMode for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
// CO2 (carbon dioxide) - support common synonyms
|
|
1580
|
+
if (status?.co2 !== undefined || status?.co2ppm !== undefined || status?.carbonDioxide !== undefined) {
|
|
1581
|
+
try {
|
|
1582
|
+
const val = Number(status?.co2 ?? status?.co2ppm ?? status?.carbonDioxide);
|
|
1583
|
+
await safeUpdate('carbonDioxide', { carbonDioxideLevel: val }, 'updateCO2');
|
|
1584
|
+
}
|
|
1585
|
+
catch (e) {
|
|
1586
|
+
this.debugLog(`Failed to apply CO2 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// PM2.5 / particulate matter
|
|
1590
|
+
if (status?.pm2_5 !== undefined || status?.pm25 !== undefined || status?.pm_2_5 !== undefined) {
|
|
1591
|
+
try {
|
|
1592
|
+
const pm = Number(status?.pm2_5 ?? status?.pm25 ?? status?.pm_2_5);
|
|
1593
|
+
await safeUpdate('pm2_5', { pm25: pm }, 'updatePM25');
|
|
1594
|
+
}
|
|
1595
|
+
catch (e) {
|
|
1596
|
+
this.debugLog(`Failed to apply PM2.5 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
// PM10 (some devices report pm10)
|
|
1600
|
+
if (status?.pm10 !== undefined) {
|
|
1601
|
+
try {
|
|
1602
|
+
const pm10 = Number(status?.pm10);
|
|
1603
|
+
await safeUpdate('pm10', { pm10 }, 'updatePM10');
|
|
1604
|
+
}
|
|
1605
|
+
catch (e) {
|
|
1606
|
+
this.debugLog(`Failed to apply PM10 for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
// VOC / TVOC - volatile organic compounds
|
|
1610
|
+
if (status?.voc !== undefined || status?.tvoc !== undefined) {
|
|
1611
|
+
try {
|
|
1612
|
+
const val = Number(status?.voc ?? status?.tvoc);
|
|
1613
|
+
await safeUpdate('voc', { voc: val }, 'updateVOC');
|
|
1614
|
+
}
|
|
1615
|
+
catch (e) {
|
|
1616
|
+
this.debugLog(`Failed to apply VOC for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
if (status?.rvcOperationalState !== undefined) {
|
|
1620
|
+
try {
|
|
1621
|
+
await safeUpdate('rvcOperationalState', { operationalState: Number(status.rvcOperationalState) }, 'updateOperationalState');
|
|
1622
|
+
}
|
|
1623
|
+
catch (e) {
|
|
1624
|
+
this.debugLog(`Failed to apply rvcOperationalState for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
catch (e) {
|
|
1629
|
+
this.debugLog(`applyStatusToAccessory top-level failure for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
140
1632
|
/**
|
|
141
1633
|
* Required for DynamicPlatformPlugin
|
|
142
1634
|
* Called when homebridge restores cached accessories from disk at startup
|
|
@@ -153,30 +1645,150 @@ export class SwitchBotMatterPlatform {
|
|
|
153
1645
|
* any custom data you stored when the accessory was originally registered.
|
|
154
1646
|
*/
|
|
155
1647
|
configureMatterAccessory(accessory) {
|
|
156
|
-
this.
|
|
1648
|
+
this.debugLog('Loading cached Matter accessory:', accessory.displayName);
|
|
157
1649
|
this.matterAccessories.set(accessory.uuid, accessory);
|
|
158
1650
|
}
|
|
159
1651
|
/**
|
|
160
1652
|
* Register all Matter accessories
|
|
161
1653
|
*/
|
|
162
1654
|
async registerMatterAccessories() {
|
|
163
|
-
this.
|
|
164
|
-
this.
|
|
165
|
-
this.
|
|
1655
|
+
this.debugLog('═'.repeat(80));
|
|
1656
|
+
this.infoLog('Homebridge Matter Plugin');
|
|
1657
|
+
this.debugLog('═'.repeat(80));
|
|
166
1658
|
// Remove accessories that are disabled in config
|
|
167
1659
|
await this.removeDisabledAccessories();
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
1660
|
+
// If we discovered real SwitchBot devices via OpenAPI, map and register them
|
|
1661
|
+
if (this.discoveredDevices && this.discoveredDevices.length > 0) {
|
|
1662
|
+
this.infoLog(`Registering ${this.discoveredDevices.length} discovered SwitchBot device(s) as Matter accessories`);
|
|
1663
|
+
// Merge device config (deviceConfig per deviceType and per-device overrides) to match HAP behavior
|
|
1664
|
+
const devicesToProcess = await this.mergeDiscoveredDevices(this.discoveredDevices);
|
|
1665
|
+
// By default, automatically remove previously-registered Matter
|
|
1666
|
+
// accessories whose deviceId is not present in the merged discovered
|
|
1667
|
+
// list. If the user explicitly sets `options.keepStaleAccessories` to
|
|
1668
|
+
// true, then we will keep previously-registered accessories (legacy
|
|
1669
|
+
// behavior).
|
|
1670
|
+
if (this.config.options?.keepStaleAccessories) {
|
|
1671
|
+
this.debugLog('Keeping previously-registered stale accessories because options.keepStaleAccessories=true');
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
try {
|
|
1675
|
+
const desiredIds = new Set((devicesToProcess || []).map((d) => this.normalizeDeviceId(d.deviceId)));
|
|
1676
|
+
const toUnregister = [];
|
|
1677
|
+
for (const [uuid, acc] of Array.from(this.matterAccessories.entries())) {
|
|
1678
|
+
try {
|
|
1679
|
+
const deviceId = acc?.context?.deviceId;
|
|
1680
|
+
if (!deviceId) {
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
1684
|
+
if (!desiredIds.has(nid)) {
|
|
1685
|
+
// Accessory exists but is no longer desired -> schedule for removal
|
|
1686
|
+
this.infoLog(`Removing previously-registered accessory for deviceId=${deviceId} (no longer discovered or configured)`);
|
|
1687
|
+
try {
|
|
1688
|
+
this.clearDeviceResources(deviceId);
|
|
1689
|
+
}
|
|
1690
|
+
catch (e) {
|
|
1691
|
+
this.debugLog(`Failed to clear resources for ${deviceId} before unregister: ${e?.message ?? e}`);
|
|
1692
|
+
}
|
|
1693
|
+
toUnregister.push(acc);
|
|
1694
|
+
this.matterAccessories.delete(uuid);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
catch (e) {
|
|
1698
|
+
this.debugLog(`Error while checking existing accessory ${uuid}: ${e?.message ?? e}`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if (toUnregister.length > 0) {
|
|
1702
|
+
try {
|
|
1703
|
+
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, toUnregister);
|
|
1704
|
+
}
|
|
1705
|
+
catch (e) {
|
|
1706
|
+
this.debugLog(`Failed to unregister accessories: ${e?.message ?? e}`);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
catch (e) {
|
|
1711
|
+
this.debugLog(`Failed to remove stale accessories: ${e?.message ?? e}`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
// We'll separate discovered devices into two buckets:
|
|
1715
|
+
// - platformAccessories: accessories that will be hosted under the plugin's Matter bridge
|
|
1716
|
+
// - roboticAccessories: robot vacuum devices which require standalone commissioning behaviour
|
|
1717
|
+
const platformAccessories = [];
|
|
1718
|
+
const roboticAccessories = [];
|
|
1719
|
+
// Known robot vacuum deviceType names (matches mapping in createAccessoryFromDevice)
|
|
1720
|
+
const robotTypes = new Set([
|
|
1721
|
+
'K10+',
|
|
1722
|
+
'K10+ Pro',
|
|
1723
|
+
'WoSweeper',
|
|
1724
|
+
'WoSweeperMini',
|
|
1725
|
+
'Robot Vacuum Cleaner S1',
|
|
1726
|
+
'Robot Vacuum Cleaner S1 Plus',
|
|
1727
|
+
'Robot Vacuum Cleaner S10',
|
|
1728
|
+
'Robot Vacuum Cleaner S1 Pro',
|
|
1729
|
+
'Robot Vacuum Cleaner S1 Mini',
|
|
1730
|
+
]);
|
|
1731
|
+
for (const dev of devicesToProcess) {
|
|
1732
|
+
try {
|
|
1733
|
+
const acc = await this.createAccessoryFromDevice(dev);
|
|
1734
|
+
if (!acc) {
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
if (robotTypes.has(dev.deviceType ?? '')) {
|
|
1738
|
+
roboticAccessories.push(acc);
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
platformAccessories.push(acc);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
catch (e) {
|
|
1745
|
+
this.errorLog(`Failed to create Matter accessory for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
// Register platform-hosted accessories (most devices)
|
|
1749
|
+
if (platformAccessories.length > 0) {
|
|
1750
|
+
this.infoLog(`✓ Registered ${platformAccessories.length} discovered platform-hosted device(s)`);
|
|
1751
|
+
for (const acc of platformAccessories) {
|
|
1752
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
1753
|
+
}
|
|
1754
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, platformAccessories);
|
|
1755
|
+
}
|
|
1756
|
+
// Register robotic accessories (robot vacuums) separately so they can be
|
|
1757
|
+
// commissioned in the way Apple Home expects (these devices often require
|
|
1758
|
+
// standalone commissioning flow). We still call registerPlatformAccessories
|
|
1759
|
+
// because the accessory implementations manage their commissioning behavior.
|
|
1760
|
+
if (roboticAccessories.length > 0) {
|
|
1761
|
+
this.infoLog(`✓ Registered ${roboticAccessories.length} discovered robot vacuum device(s)`);
|
|
1762
|
+
for (const acc of roboticAccessories) {
|
|
1763
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
1764
|
+
}
|
|
1765
|
+
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, roboticAccessories);
|
|
1766
|
+
}
|
|
1767
|
+
// Debug/info: how many discovered vs example accessories were registered.
|
|
1768
|
+
// Example accessories are disabled — we intentionally do NOT register them.
|
|
1769
|
+
const discoveredRegistered = platformAccessories.length + roboticAccessories.length;
|
|
1770
|
+
const exampleRegistered = 0;
|
|
1771
|
+
this.debugLog(`Discovered accessories registered: ${discoveredRegistered}; Example accessories registered: ${exampleRegistered}`);
|
|
1772
|
+
// Dump registry state to help runtime debugging: which accessory instances
|
|
1773
|
+
// were created and which refresh timers are scheduled. This helps confirm
|
|
1774
|
+
// whether safeUpdate will prefer accessory helpers and whether periodic
|
|
1775
|
+
// refreshes exist for each device.
|
|
1776
|
+
try {
|
|
1777
|
+
const instanceKeys = Array.from(this.accessoryInstances.keys());
|
|
1778
|
+
this.debugLog(`Accessory instances registered (${instanceKeys.length}): ${JSON.stringify(instanceKeys)}`);
|
|
1779
|
+
const timerKeys = Array.from(this.refreshTimers.keys());
|
|
1780
|
+
this.debugLog(`Refresh timers scheduled (${timerKeys.length}): ${JSON.stringify(timerKeys)}`);
|
|
1781
|
+
}
|
|
1782
|
+
catch (e) {
|
|
1783
|
+
this.debugLog(`Failed to dump platform registries: ${e?.message ?? e}`);
|
|
1784
|
+
}
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
// If no discovered devices are available, do not register example/demo accessories.
|
|
1788
|
+
this.infoLog('No discovered SwitchBot devices found.');
|
|
1789
|
+
this.debugLog('═'.repeat(80));
|
|
1790
|
+
this.debugLog('Finished registering Matter accessories');
|
|
1791
|
+
this.debugLog('═'.repeat(80));
|
|
180
1792
|
}
|
|
181
1793
|
/**
|
|
182
1794
|
* Remove accessories that are disabled in config
|
|
@@ -209,7 +1821,17 @@ export class SwitchBotMatterPlatform {
|
|
|
209
1821
|
if (enabled === false) {
|
|
210
1822
|
const existingAccessory = this.matterAccessories.get(uuid);
|
|
211
1823
|
if (existingAccessory) {
|
|
212
|
-
this.
|
|
1824
|
+
this.infoLog(`Removing accessory '${name}' (disabled in config)`);
|
|
1825
|
+
// Attempt to clear any per-device resources (timers, BLE handlers, instances)
|
|
1826
|
+
try {
|
|
1827
|
+
const deviceId = existingAccessory?.context?.deviceId;
|
|
1828
|
+
if (deviceId) {
|
|
1829
|
+
this.clearDeviceResources(deviceId);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
catch (e) {
|
|
1833
|
+
this.debugLog(`Failed to clear resources for disabled accessory ${name}: ${e?.message ?? e}`);
|
|
1834
|
+
}
|
|
213
1835
|
await this.api.matter.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
|
|
214
1836
|
this.matterAccessories.delete(uuid);
|
|
215
1837
|
}
|
|
@@ -220,9 +1842,9 @@ export class SwitchBotMatterPlatform {
|
|
|
220
1842
|
* Section 4: Lighting Devices (Matter Spec § 4)
|
|
221
1843
|
*/
|
|
222
1844
|
async registerSection4Lighting() {
|
|
223
|
-
this.
|
|
224
|
-
this.
|
|
225
|
-
this.
|
|
1845
|
+
this.debugLog('═'.repeat(80));
|
|
1846
|
+
this.infoLog('Section 4: Lighting Devices (Matter Spec § 4)');
|
|
1847
|
+
this.debugLog('═'.repeat(80));
|
|
226
1848
|
const accessories = [];
|
|
227
1849
|
// On/Off Light
|
|
228
1850
|
if (this.config.enableOnOffLight !== false) {
|
|
@@ -250,9 +1872,9 @@ export class SwitchBotMatterPlatform {
|
|
|
250
1872
|
accessories.push(device.toAccessory());
|
|
251
1873
|
}
|
|
252
1874
|
if (accessories.length > 0) {
|
|
253
|
-
this.
|
|
1875
|
+
this.infoLog(`✓ Registered ${accessories.length} lighting device(s)`);
|
|
254
1876
|
for (const acc of accessories) {
|
|
255
|
-
this.
|
|
1877
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
256
1878
|
}
|
|
257
1879
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
258
1880
|
}
|
|
@@ -261,9 +1883,9 @@ export class SwitchBotMatterPlatform {
|
|
|
261
1883
|
* Section 5: Smart Plugs/Actuators (Matter Spec § 5)
|
|
262
1884
|
*/
|
|
263
1885
|
async registerSection5SmartPlugs() {
|
|
264
|
-
this.
|
|
265
|
-
this.
|
|
266
|
-
this.
|
|
1886
|
+
this.debugLog('═'.repeat(80));
|
|
1887
|
+
this.infoLog('Section 5: Smart Plugs/Actuators (Matter Spec § 5)');
|
|
1888
|
+
this.debugLog('═'.repeat(80));
|
|
267
1889
|
const accessories = [];
|
|
268
1890
|
// On/Off Outlet
|
|
269
1891
|
if (this.config.enableOnOffOutlet !== false) {
|
|
@@ -271,9 +1893,9 @@ export class SwitchBotMatterPlatform {
|
|
|
271
1893
|
accessories.push(device.toAccessory());
|
|
272
1894
|
}
|
|
273
1895
|
if (accessories.length > 0) {
|
|
274
|
-
this.
|
|
1896
|
+
this.infoLog(`✓ Registered ${accessories.length} smart plug/actuator device(s)`);
|
|
275
1897
|
for (const acc of accessories) {
|
|
276
|
-
this.
|
|
1898
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
277
1899
|
}
|
|
278
1900
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
279
1901
|
}
|
|
@@ -282,9 +1904,9 @@ export class SwitchBotMatterPlatform {
|
|
|
282
1904
|
* Section 6: Switches & Controllers (Matter Spec § 6)
|
|
283
1905
|
*/
|
|
284
1906
|
async registerSection6Switches() {
|
|
285
|
-
this.
|
|
286
|
-
this.
|
|
287
|
-
this.
|
|
1907
|
+
this.debugLog('═'.repeat(80));
|
|
1908
|
+
this.infoLog('Section 6: Switches & Controllers (Matter Spec § 6)');
|
|
1909
|
+
this.debugLog('═'.repeat(80));
|
|
288
1910
|
const accessories = [];
|
|
289
1911
|
// On/Off Switch
|
|
290
1912
|
if (this.config.enableOnOffSwitch !== false) {
|
|
@@ -292,9 +1914,9 @@ export class SwitchBotMatterPlatform {
|
|
|
292
1914
|
accessories.push(device.toAccessory());
|
|
293
1915
|
}
|
|
294
1916
|
if (accessories.length > 0) {
|
|
295
|
-
this.
|
|
1917
|
+
this.infoLog(`✓ Registered ${accessories.length} switch/controller device(s)`);
|
|
296
1918
|
for (const acc of accessories) {
|
|
297
|
-
this.
|
|
1919
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
298
1920
|
}
|
|
299
1921
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
300
1922
|
}
|
|
@@ -303,9 +1925,9 @@ export class SwitchBotMatterPlatform {
|
|
|
303
1925
|
* Section 7: Sensors (Matter Spec § 7)
|
|
304
1926
|
*/
|
|
305
1927
|
async registerSection7Sensors() {
|
|
306
|
-
this.
|
|
307
|
-
this.
|
|
308
|
-
this.
|
|
1928
|
+
this.debugLog('═'.repeat(80));
|
|
1929
|
+
this.infoLog('Section 7: Sensors (Matter Spec § 7)');
|
|
1930
|
+
this.debugLog('═'.repeat(80));
|
|
309
1931
|
const accessories = [];
|
|
310
1932
|
// Contact Sensor
|
|
311
1933
|
if (this.config.enableContactSensor !== false) {
|
|
@@ -343,9 +1965,9 @@ export class SwitchBotMatterPlatform {
|
|
|
343
1965
|
accessories.push(device.toAccessory());
|
|
344
1966
|
}
|
|
345
1967
|
if (accessories.length > 0) {
|
|
346
|
-
this.
|
|
1968
|
+
this.infoLog(`✓ Registered ${accessories.length} sensor device(s)`);
|
|
347
1969
|
for (const acc of accessories) {
|
|
348
|
-
this.
|
|
1970
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
349
1971
|
}
|
|
350
1972
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
351
1973
|
}
|
|
@@ -354,9 +1976,9 @@ export class SwitchBotMatterPlatform {
|
|
|
354
1976
|
* Section 8: Closure Devices (Matter Spec § 8)
|
|
355
1977
|
*/
|
|
356
1978
|
async registerSection8Closure() {
|
|
357
|
-
this.
|
|
358
|
-
this.
|
|
359
|
-
this.
|
|
1979
|
+
this.debugLog('═'.repeat(80));
|
|
1980
|
+
this.infoLog('Section 8: Closure Devices (Matter Spec § 8)');
|
|
1981
|
+
this.debugLog('═'.repeat(80));
|
|
360
1982
|
const accessories = [];
|
|
361
1983
|
// Door Lock
|
|
362
1984
|
if (this.config.enableDoorLock !== false) {
|
|
@@ -374,9 +1996,9 @@ export class SwitchBotMatterPlatform {
|
|
|
374
1996
|
accessories.push(device.toAccessory());
|
|
375
1997
|
}
|
|
376
1998
|
if (accessories.length > 0) {
|
|
377
|
-
this.
|
|
1999
|
+
this.infoLog(`✓ Registered ${accessories.length} closure device(s)`);
|
|
378
2000
|
for (const acc of accessories) {
|
|
379
|
-
this.
|
|
2001
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
380
2002
|
}
|
|
381
2003
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
382
2004
|
}
|
|
@@ -385,9 +2007,9 @@ export class SwitchBotMatterPlatform {
|
|
|
385
2007
|
* Section 9: HVAC (Matter Spec § 9)
|
|
386
2008
|
*/
|
|
387
2009
|
async registerSection9HVAC() {
|
|
388
|
-
this.
|
|
389
|
-
this.
|
|
390
|
-
this.
|
|
2010
|
+
this.debugLog('═'.repeat(80));
|
|
2011
|
+
this.infoLog('Section 9: HVAC (Matter Spec § 9)');
|
|
2012
|
+
this.debugLog('═'.repeat(80));
|
|
391
2013
|
const accessories = [];
|
|
392
2014
|
// Thermostat
|
|
393
2015
|
if (this.config.enableThermostat !== false) {
|
|
@@ -400,9 +2022,9 @@ export class SwitchBotMatterPlatform {
|
|
|
400
2022
|
accessories.push(device.toAccessory());
|
|
401
2023
|
}
|
|
402
2024
|
if (accessories.length > 0) {
|
|
403
|
-
this.
|
|
2025
|
+
this.infoLog(`✓ Registered ${accessories.length} HVAC device(s)`);
|
|
404
2026
|
for (const acc of accessories) {
|
|
405
|
-
this.
|
|
2027
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
406
2028
|
}
|
|
407
2029
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
408
2030
|
}
|
|
@@ -414,9 +2036,9 @@ export class SwitchBotMatterPlatform {
|
|
|
414
2036
|
* Use those codes to pair the vacuum as a separate bridge in your Home app.
|
|
415
2037
|
*/
|
|
416
2038
|
async registerSection12Robotic() {
|
|
417
|
-
this.
|
|
418
|
-
this.
|
|
419
|
-
this.
|
|
2039
|
+
this.debugLog('═'.repeat(80));
|
|
2040
|
+
this.infoLog('Section 12: Robotic Devices (Matter Spec § 12)');
|
|
2041
|
+
this.debugLog('═'.repeat(80));
|
|
420
2042
|
const accessories = [];
|
|
421
2043
|
// Robot Vacuum
|
|
422
2044
|
if (this.config.enableRobotVacuum !== false) {
|
|
@@ -424,9 +2046,9 @@ export class SwitchBotMatterPlatform {
|
|
|
424
2046
|
accessories.push(device.toAccessory());
|
|
425
2047
|
}
|
|
426
2048
|
if (accessories.length > 0) {
|
|
427
|
-
this.
|
|
2049
|
+
this.infoLog(`✓ Registered ${accessories.length} robot vacuum device(s)`);
|
|
428
2050
|
for (const acc of accessories) {
|
|
429
|
-
this.
|
|
2051
|
+
this.infoLog(` - ${acc.displayName} (standalone for Apple Home compatibility)`);
|
|
430
2052
|
}
|
|
431
2053
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
432
2054
|
}
|
|
@@ -439,9 +2061,9 @@ export class SwitchBotMatterPlatform {
|
|
|
439
2061
|
* like managing multiple logical components within a single device.
|
|
440
2062
|
*/
|
|
441
2063
|
async registerCustomDevices() {
|
|
442
|
-
this.
|
|
443
|
-
this.
|
|
444
|
-
this.
|
|
2064
|
+
this.debugLog('═'.repeat(80));
|
|
2065
|
+
this.infoLog('Custom Devices');
|
|
2066
|
+
this.debugLog('═'.repeat(80));
|
|
445
2067
|
const accessories = [];
|
|
446
2068
|
// Power Strip (4 Outlets)
|
|
447
2069
|
if (this.config.enablePowerStrip !== false) {
|
|
@@ -449,12 +2071,151 @@ export class SwitchBotMatterPlatform {
|
|
|
449
2071
|
accessories.push(device.toAccessory());
|
|
450
2072
|
}
|
|
451
2073
|
if (accessories.length > 0) {
|
|
452
|
-
this.
|
|
2074
|
+
this.infoLog(`✓ Registered ${accessories.length} custom device(s)`);
|
|
453
2075
|
for (const acc of accessories) {
|
|
454
|
-
this.
|
|
2076
|
+
this.infoLog(` - ${acc.displayName}`);
|
|
455
2077
|
}
|
|
456
2078
|
await this.api.matter.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories);
|
|
457
2079
|
}
|
|
458
2080
|
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Start platform-level refresh timer to batch all device status updates
|
|
2083
|
+
*/
|
|
2084
|
+
startPlatformRefreshTimer(refreshRateSec) {
|
|
2085
|
+
// Only create timer once
|
|
2086
|
+
if (this.platformRefreshTimer) {
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
// Respect user toggle
|
|
2090
|
+
if (this.config.options?.matterBatchEnabled === false) {
|
|
2091
|
+
this.infoLog('Matter batch refresh is disabled by configuration');
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
const jitterSec = Number(this.config.options?.matterBatchJitter ?? 0);
|
|
2095
|
+
const intervalMs = Number(refreshRateSec) * 1000;
|
|
2096
|
+
const jitterMs = Number.isFinite(jitterSec) && jitterSec > 0 ? Math.floor(Math.random() * jitterSec * 1000) : 0;
|
|
2097
|
+
// Start after optional jitter, then schedule recurring interval
|
|
2098
|
+
setTimeout(async () => {
|
|
2099
|
+
try {
|
|
2100
|
+
await this.batchRefreshAllDevices();
|
|
2101
|
+
}
|
|
2102
|
+
catch (e) {
|
|
2103
|
+
this.debugLog(`Initial batch refresh failed: ${e?.message ?? e}`);
|
|
2104
|
+
}
|
|
2105
|
+
this.platformRefreshTimer = setInterval(async () => {
|
|
2106
|
+
await this.batchRefreshAllDevices();
|
|
2107
|
+
}, intervalMs);
|
|
2108
|
+
}, jitterMs);
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Batch refresh all devices - still makes individual API calls but batches them together
|
|
2112
|
+
* Note: SwitchBot API doesn't support true batch status calls, but we can parallelize them
|
|
2113
|
+
*/
|
|
2114
|
+
async batchRefreshAllDevices() {
|
|
2115
|
+
if (!this.switchBotAPI) {
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
this.debugLog('Performing batched periodic OpenAPI refresh for all devices');
|
|
2119
|
+
// Build list from registered accessory instances (uuid) and discovered devices
|
|
2120
|
+
const devicesToRefresh = [];
|
|
2121
|
+
try {
|
|
2122
|
+
for (const [nid, instance] of this.accessoryInstances.entries()) {
|
|
2123
|
+
const uuid = instance?.uuid;
|
|
2124
|
+
if (!uuid) {
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
const dev = this.discoveredDevices.find(d => this.normalizeDeviceId(d.deviceId) === nid);
|
|
2128
|
+
// Skip devices with per-device timers and those in cooldown
|
|
2129
|
+
const now = Date.now();
|
|
2130
|
+
const nextAllowed = this.backoffCooldowns.get(nid) ?? 0;
|
|
2131
|
+
if (dev && !this.perDeviceRefreshSet.has(nid) && now >= nextAllowed) {
|
|
2132
|
+
devicesToRefresh.push({ uuid, dev });
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
catch (e) {
|
|
2137
|
+
this.errorLog(`Failed to enumerate devices for batch refresh: ${e?.message ?? e}`);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (devicesToRefresh.length === 0) {
|
|
2141
|
+
this.debugLog('No devices to refresh');
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
this.debugLog(`Refreshing ${devicesToRefresh.length} devices in parallel batch`);
|
|
2145
|
+
// Randomize order to reduce synchronized spikes
|
|
2146
|
+
for (let i = devicesToRefresh.length - 1; i > 0; i--) {
|
|
2147
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
2148
|
+
[devicesToRefresh[i], devicesToRefresh[j]] = [devicesToRefresh[j], devicesToRefresh[i]];
|
|
2149
|
+
}
|
|
2150
|
+
const concurrency = Number(this.config.options?.matterBatchConcurrency ?? 5);
|
|
2151
|
+
await this.runWithConcurrency(devicesToRefresh, async ({ uuid, dev }) => {
|
|
2152
|
+
try {
|
|
2153
|
+
const status = await this.refreshSingleDeviceWithRetry(dev);
|
|
2154
|
+
if (status) {
|
|
2155
|
+
await this.applyStatusToAccessory(uuid, dev, status);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
catch (e) {
|
|
2159
|
+
this.errorLog(`Periodic OpenAPI refresh failed for ${dev.deviceId}: ${e?.message ?? e}`);
|
|
2160
|
+
}
|
|
2161
|
+
}, Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 5);
|
|
2162
|
+
this.debugLog(`Batch refresh completed for ${devicesToRefresh.length} devices`);
|
|
2163
|
+
}
|
|
2164
|
+
/** Refresh a single device with retry and backoff; returns status object if successful */
|
|
2165
|
+
async refreshSingleDeviceWithRetry(dev, retries = 3, baseDelayMs = 500) {
|
|
2166
|
+
const deviceId = dev.deviceId;
|
|
2167
|
+
let attempt = 0;
|
|
2168
|
+
while (attempt <= retries) {
|
|
2169
|
+
try {
|
|
2170
|
+
this.apiTracker?.track();
|
|
2171
|
+
const { response, statusCode } = await this.switchBotAPI.getDeviceStatus(deviceId, this.config.credentials?.token, this.config.credentials?.secret);
|
|
2172
|
+
const respAny = response;
|
|
2173
|
+
const body = respAny?.body ?? respAny;
|
|
2174
|
+
if (statusCode === 100 || statusCode === 200) {
|
|
2175
|
+
const status = body?.status ?? body;
|
|
2176
|
+
this.deviceStatusCache.set(this.normalizeDeviceId(deviceId), { status, timestamp: Date.now() });
|
|
2177
|
+
this.debugLog(`OpenAPI refresh succeeded for ${deviceId} (attempt ${attempt + 1})`);
|
|
2178
|
+
return status;
|
|
2179
|
+
}
|
|
2180
|
+
this.debugLog(`OpenAPI refresh unexpected statusCode=${statusCode} for ${deviceId} (attempt ${attempt + 1})`);
|
|
2181
|
+
}
|
|
2182
|
+
catch (e) {
|
|
2183
|
+
this.debugLog(`OpenAPI refresh error for ${deviceId} (attempt ${attempt + 1}): ${e?.message ?? e}`);
|
|
2184
|
+
}
|
|
2185
|
+
// backoff before next retry if any left
|
|
2186
|
+
attempt++;
|
|
2187
|
+
if (attempt <= retries) {
|
|
2188
|
+
const delay = baseDelayMs * (2 ** (attempt - 1));
|
|
2189
|
+
await sleep(delay);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
// Set a cooldown after exhausting retries to avoid hammering problematic devices
|
|
2193
|
+
try {
|
|
2194
|
+
const nid = this.normalizeDeviceId(deviceId);
|
|
2195
|
+
const cooldownMs = Math.max(60_000, baseDelayMs * (2 ** retries) * 10);
|
|
2196
|
+
this.backoffCooldowns.set(nid, Date.now() + cooldownMs);
|
|
2197
|
+
this.debugLog(`Applied cooldown for ${deviceId}: ${Math.round(cooldownMs / 1000)}s`);
|
|
2198
|
+
}
|
|
2199
|
+
catch { }
|
|
2200
|
+
return null;
|
|
2201
|
+
}
|
|
2202
|
+
/** Simple concurrency limiter for an array of items */
|
|
2203
|
+
async runWithConcurrency(items, worker, concurrency) {
|
|
2204
|
+
const queue = items.slice();
|
|
2205
|
+
const workers = [];
|
|
2206
|
+
const runNext = async () => {
|
|
2207
|
+
const item = queue.shift();
|
|
2208
|
+
if (!item) {
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
await worker(item);
|
|
2212
|
+
return runNext();
|
|
2213
|
+
};
|
|
2214
|
+
const pool = Math.min(concurrency, Math.max(1, items.length));
|
|
2215
|
+
for (let i = 0; i < pool; i++) {
|
|
2216
|
+
workers.push(runNext());
|
|
2217
|
+
}
|
|
2218
|
+
await Promise.all(workers);
|
|
2219
|
+
}
|
|
459
2220
|
}
|
|
460
2221
|
//# sourceMappingURL=platform-matter.js.map
|