@switchbot/homebridge-switchbot 5.0.0-beta.3 → 5.0.0-beta.30

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