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