@switchbot/homebridge-switchbot 5.0.0-beta.5 → 5.0.0-beta.51

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