@switchbot/homebridge-switchbot 5.0.0-beta.70 → 5.0.0-beta.72

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