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

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