@switchbot/homebridge-switchbot 5.0.0-beta.6 → 5.0.0-beta.60

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