@switchbot/homebridge-switchbot 5.0.0-beta.99 → 5.0.0

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 (282) hide show
  1. package/.changeset/config.json +14 -0
  2. package/.github/copilot-instructions.md +39 -0
  3. package/.github/workflows/ci.yml +4 -1
  4. package/.github/workflows/manual-e2e.yml +6 -3
  5. package/.github/workflows/release.yml +64 -15
  6. package/.github/workflows/stale.yml +2 -4
  7. package/.husky/pre-push +15 -0
  8. package/CHANGELOG.md +126 -134
  9. package/MIGRATION.md +16 -6
  10. package/README.md +84 -3
  11. package/TODO.md +263 -0
  12. package/config.schema.json +229 -36
  13. package/dist/SwitchBotHAPPlatform.d.ts +133 -0
  14. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
  15. package/dist/SwitchBotHAPPlatform.js +555 -0
  16. package/dist/SwitchBotHAPPlatform.js.map +1 -0
  17. package/dist/SwitchBotMatterPlatform.d.ts +141 -0
  18. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
  19. package/dist/SwitchBotMatterPlatform.js +536 -0
  20. package/dist/SwitchBotMatterPlatform.js.map +1 -0
  21. package/dist/device-types.d.ts +31 -0
  22. package/dist/device-types.d.ts.map +1 -0
  23. package/dist/device-types.js +246 -0
  24. package/dist/device-types.js.map +1 -0
  25. package/dist/deviceCommandMapper.d.ts +10 -0
  26. package/dist/deviceCommandMapper.d.ts.map +1 -0
  27. package/dist/deviceCommandMapper.js +319 -0
  28. package/dist/deviceCommandMapper.js.map +1 -0
  29. package/dist/deviceFactory.d.ts +3 -2
  30. package/dist/deviceFactory.d.ts.map +1 -1
  31. package/dist/deviceFactory.js +107 -29
  32. package/dist/deviceFactory.js.map +1 -1
  33. package/dist/devices/genericDevice.d.ts +59 -37
  34. package/dist/devices/genericDevice.d.ts.map +1 -1
  35. package/dist/devices/genericDevice.js +376 -78
  36. package/dist/devices/genericDevice.js.map +1 -1
  37. package/dist/errors.d.ts +38 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +32 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/homebridge-ui/device-types.js +246 -0
  42. package/dist/homebridge-ui/device-types.js.map +1 -0
  43. package/dist/homebridge-ui/deviceCommandMapper.js +319 -0
  44. package/dist/homebridge-ui/deviceCommandMapper.js.map +1 -0
  45. package/dist/homebridge-ui/endpoints/config.d.ts +3 -0
  46. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -0
  47. package/dist/homebridge-ui/endpoints/config.js +90 -0
  48. package/dist/homebridge-ui/endpoints/config.js.map +1 -0
  49. package/dist/homebridge-ui/endpoints/devices.d.ts +6 -0
  50. package/dist/homebridge-ui/endpoints/devices.d.ts.map +1 -0
  51. package/dist/homebridge-ui/endpoints/devices.js +144 -0
  52. package/dist/homebridge-ui/endpoints/devices.js.map +1 -0
  53. package/dist/homebridge-ui/endpoints/discovery.d.ts +7 -0
  54. package/dist/homebridge-ui/endpoints/discovery.d.ts.map +1 -0
  55. package/dist/homebridge-ui/endpoints/discovery.js +219 -0
  56. package/dist/homebridge-ui/endpoints/discovery.js.map +1 -0
  57. package/dist/homebridge-ui/errors.js +32 -0
  58. package/dist/homebridge-ui/errors.js.map +1 -0
  59. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +90 -0
  60. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -0
  61. package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js +144 -0
  62. package/dist/homebridge-ui/homebridge-ui/endpoints/devices.js.map +1 -0
  63. package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js +219 -0
  64. package/dist/homebridge-ui/homebridge-ui/endpoints/discovery.js.map +1 -0
  65. package/dist/homebridge-ui/homebridge-ui/server.js +11 -0
  66. package/dist/homebridge-ui/homebridge-ui/server.js.map +1 -0
  67. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +108 -0
  68. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -0
  69. package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js +111 -0
  70. package/dist/homebridge-ui/homebridge-ui/utils/device-migration.js.map +1 -0
  71. package/dist/homebridge-ui/homebridge-ui/utils/logger.js +17 -0
  72. package/dist/homebridge-ui/homebridge-ui/utils/logger.js.map +1 -0
  73. package/dist/homebridge-ui/public/css/styles.css +483 -0
  74. package/dist/homebridge-ui/public/index.html +197 -621
  75. package/dist/homebridge-ui/public/js/advanced-settings.d.ts +3 -0
  76. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -0
  77. package/dist/homebridge-ui/public/js/advanced-settings.js +95 -0
  78. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -0
  79. package/dist/homebridge-ui/public/js/advanced-settings.ts +94 -0
  80. package/dist/homebridge-ui/public/js/api.d.ts +66 -0
  81. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -0
  82. package/dist/homebridge-ui/public/js/api.js +295 -0
  83. package/dist/homebridge-ui/public/js/api.js.map +1 -0
  84. package/dist/homebridge-ui/public/js/api.ts +355 -0
  85. package/dist/homebridge-ui/public/js/app.d.ts +2 -0
  86. package/dist/homebridge-ui/public/js/app.d.ts.map +1 -0
  87. package/dist/homebridge-ui/public/js/app.js +3722 -0
  88. package/dist/homebridge-ui/public/js/app.js.map +7 -0
  89. package/dist/homebridge-ui/public/js/app.ts +22 -0
  90. package/dist/homebridge-ui/public/js/constants.d.ts +2 -0
  91. package/dist/homebridge-ui/public/js/constants.d.ts.map +1 -0
  92. package/dist/homebridge-ui/public/js/constants.js +2 -0
  93. package/dist/homebridge-ui/public/js/constants.js.map +1 -0
  94. package/dist/homebridge-ui/public/js/constants.ts +1 -0
  95. package/dist/homebridge-ui/public/js/credentials.d.ts +3 -0
  96. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -0
  97. package/dist/homebridge-ui/public/js/credentials.js +99 -0
  98. package/dist/homebridge-ui/public/js/credentials.js.map +1 -0
  99. package/dist/homebridge-ui/public/js/credentials.ts +105 -0
  100. package/dist/homebridge-ui/public/js/devices-delete.d.ts +3 -0
  101. package/dist/homebridge-ui/public/js/devices-delete.d.ts.map +1 -0
  102. package/dist/homebridge-ui/public/js/devices-delete.js +199 -0
  103. package/dist/homebridge-ui/public/js/devices-delete.js.map +1 -0
  104. package/dist/homebridge-ui/public/js/devices-delete.ts +227 -0
  105. package/dist/homebridge-ui/public/js/devices.d.ts +9 -0
  106. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -0
  107. package/dist/homebridge-ui/public/js/devices.js +98 -0
  108. package/dist/homebridge-ui/public/js/devices.js.map +1 -0
  109. package/dist/homebridge-ui/public/js/devices.ts +106 -0
  110. package/dist/homebridge-ui/public/js/discovery.d.ts +9 -0
  111. package/dist/homebridge-ui/public/js/discovery.d.ts.map +1 -0
  112. package/dist/homebridge-ui/public/js/discovery.js +1201 -0
  113. package/dist/homebridge-ui/public/js/discovery.js.map +1 -0
  114. package/dist/homebridge-ui/public/js/discovery.ts +1335 -0
  115. package/dist/homebridge-ui/public/js/logger.d.ts +7 -0
  116. package/dist/homebridge-ui/public/js/logger.d.ts.map +1 -0
  117. package/dist/homebridge-ui/public/js/logger.js +17 -0
  118. package/dist/homebridge-ui/public/js/logger.js.map +1 -0
  119. package/dist/homebridge-ui/public/js/logger.ts +17 -0
  120. package/dist/homebridge-ui/public/js/modal.d.ts +5 -0
  121. package/dist/homebridge-ui/public/js/modal.d.ts.map +1 -0
  122. package/dist/homebridge-ui/public/js/modal.js +35 -0
  123. package/dist/homebridge-ui/public/js/modal.js.map +1 -0
  124. package/dist/homebridge-ui/public/js/modal.ts +35 -0
  125. package/dist/homebridge-ui/public/js/modals.d.ts +15 -0
  126. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -0
  127. package/dist/homebridge-ui/public/js/modals.js +675 -0
  128. package/dist/homebridge-ui/public/js/modals.js.map +1 -0
  129. package/dist/homebridge-ui/public/js/modals.ts +765 -0
  130. package/dist/homebridge-ui/public/js/render.d.ts +71 -0
  131. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -0
  132. package/dist/homebridge-ui/public/js/render.js +960 -0
  133. package/dist/homebridge-ui/public/js/render.js.map +1 -0
  134. package/dist/homebridge-ui/public/js/render.ts +1084 -0
  135. package/dist/homebridge-ui/public/js/toast.d.ts +6 -0
  136. package/dist/homebridge-ui/public/js/toast.d.ts.map +1 -0
  137. package/dist/homebridge-ui/public/js/toast.js +38 -0
  138. package/dist/homebridge-ui/public/js/toast.js.map +1 -0
  139. package/dist/homebridge-ui/public/js/toast.ts +44 -0
  140. package/dist/homebridge-ui/public/js/types.d.ts +23 -0
  141. package/dist/homebridge-ui/public/js/types.d.ts.map +1 -0
  142. package/dist/homebridge-ui/public/js/types.js +2 -0
  143. package/dist/homebridge-ui/public/js/types.js.map +1 -0
  144. package/dist/homebridge-ui/public/js/types.ts +26 -0
  145. package/dist/homebridge-ui/server.d.ts +1 -3
  146. package/dist/homebridge-ui/server.d.ts.map +1 -1
  147. package/dist/homebridge-ui/server.js +8 -471
  148. package/dist/homebridge-ui/server.js.map +1 -1
  149. package/dist/homebridge-ui/settings.js +8 -0
  150. package/dist/homebridge-ui/settings.js.map +1 -0
  151. package/dist/homebridge-ui/switchbotClient.js +247 -0
  152. package/dist/homebridge-ui/switchbotClient.js.map +1 -0
  153. package/dist/homebridge-ui/utils/config-parser.d.ts +39 -0
  154. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -0
  155. package/dist/homebridge-ui/utils/config-parser.js +108 -0
  156. package/dist/homebridge-ui/utils/config-parser.js.map +1 -0
  157. package/dist/homebridge-ui/utils/device-migration.d.ts +35 -0
  158. package/dist/homebridge-ui/utils/device-migration.d.ts.map +1 -0
  159. package/dist/homebridge-ui/utils/device-migration.js +111 -0
  160. package/dist/homebridge-ui/utils/device-migration.js.map +1 -0
  161. package/dist/homebridge-ui/utils/logger.d.ts +7 -0
  162. package/dist/homebridge-ui/utils/logger.d.ts.map +1 -0
  163. package/dist/homebridge-ui/utils/logger.js +17 -0
  164. package/dist/homebridge-ui/utils/logger.js.map +1 -0
  165. package/dist/index.d.ts +10 -0
  166. package/dist/index.d.ts.map +1 -1
  167. package/dist/index.js +12 -2
  168. package/dist/index.js.map +1 -1
  169. package/dist/settings.d.ts +1 -0
  170. package/dist/settings.d.ts.map +1 -1
  171. package/dist/settings.js +1 -0
  172. package/dist/settings.js.map +1 -1
  173. package/dist/switchbotClient.d.ts +12 -10
  174. package/dist/switchbotClient.d.ts.map +1 -1
  175. package/dist/switchbotClient.js +156 -103
  176. package/dist/switchbotClient.js.map +1 -1
  177. package/dist/utils.d.ts +76 -1
  178. package/dist/utils.d.ts.map +1 -1
  179. package/dist/utils.js +1121 -4
  180. package/dist/utils.js.map +1 -1
  181. package/docs/assets/highlight.css +16 -2
  182. package/docs/assets/main.js +1 -1
  183. package/docs/index.html +82 -5
  184. package/docs/variables/default.html +3 -1
  185. package/eslint.config.js +9 -5
  186. package/nodemon.json +2 -2
  187. package/package.json +34 -21
  188. package/scripts/build-ui.js +37 -0
  189. package/scripts/free-dev-ports.mjs +105 -0
  190. package/scripts/generate-matter-maps.js +34 -17
  191. package/scripts/sync-device-types.mjs +31 -0
  192. package/src/SwitchBotHAPPlatform.ts +558 -0
  193. package/src/SwitchBotMatterPlatform.ts +538 -0
  194. package/src/device-types.js +246 -0
  195. package/src/device-types.js.map +1 -0
  196. package/src/device-types.ts +261 -0
  197. package/src/deviceCommandMapper.js +319 -0
  198. package/src/deviceCommandMapper.js.map +1 -0
  199. package/src/deviceCommandMapper.ts +333 -0
  200. package/src/deviceFactory.ts +125 -45
  201. package/src/devices/genericDevice.ts +411 -69
  202. package/src/errors.js +32 -0
  203. package/src/errors.js.map +1 -0
  204. package/src/errors.ts +35 -0
  205. package/src/homebridge-ui/endpoints/config.ts +110 -0
  206. package/src/homebridge-ui/endpoints/devices.ts +153 -0
  207. package/src/homebridge-ui/endpoints/discovery.ts +240 -0
  208. package/src/homebridge-ui/public/css/styles.css +483 -0
  209. package/src/homebridge-ui/public/index.html +197 -621
  210. package/src/homebridge-ui/public/js/advanced-settings.ts +94 -0
  211. package/src/homebridge-ui/public/js/api.ts +355 -0
  212. package/src/homebridge-ui/public/js/app.ts +22 -0
  213. package/src/homebridge-ui/public/js/constants.ts +1 -0
  214. package/src/homebridge-ui/public/js/credentials.ts +105 -0
  215. package/src/homebridge-ui/public/js/devices-delete.ts +227 -0
  216. package/src/homebridge-ui/public/js/devices.ts +106 -0
  217. package/src/homebridge-ui/public/js/discovery.ts +1335 -0
  218. package/src/homebridge-ui/public/js/logger.ts +17 -0
  219. package/src/homebridge-ui/public/js/modal.ts +35 -0
  220. package/src/homebridge-ui/public/js/modals.ts +765 -0
  221. package/src/homebridge-ui/public/js/render.ts +1084 -0
  222. package/src/homebridge-ui/public/js/toast.ts +44 -0
  223. package/src/homebridge-ui/public/js/types.ts +26 -0
  224. package/src/homebridge-ui/server.ts +9 -554
  225. package/src/homebridge-ui/utils/config-parser.ts +125 -0
  226. package/src/homebridge-ui/utils/device-migration.ts +144 -0
  227. package/src/homebridge-ui/utils/logger.ts +17 -0
  228. package/src/index.ts +12 -2
  229. package/src/settings.js +8 -0
  230. package/src/settings.js.map +1 -0
  231. package/src/settings.ts +2 -0
  232. package/src/switchbotClient.js +247 -0
  233. package/src/switchbotClient.js.map +1 -0
  234. package/src/switchbotClient.ts +177 -114
  235. package/src/utils.ts +1133 -5
  236. package/test/client/switchbot-client-debounce.spec.ts +35 -0
  237. package/test/client/switchbot-client-openapi.spec.ts +19 -0
  238. package/test/client/switchbotClient.spec.ts +64 -0
  239. package/test/device/device-mapping.spec.ts +23 -0
  240. package/test/device/deviceBase.spec.ts +26 -0
  241. package/test/device/deviceFactory-edge.spec.ts +15 -0
  242. package/test/device/deviceFactory.spec.ts +33 -0
  243. package/test/device/fan-swing.spec.ts +34 -0
  244. package/test/device/genericDevice-blepoll.spec.ts +47 -0
  245. package/test/device/irdevice.spec.ts +9 -0
  246. package/test/device/lock-users.spec.ts +35 -0
  247. package/test/device/matter-descriptors.spec.ts +22 -0
  248. package/test/device/matter-device-state.spec.ts +37 -0
  249. package/test/e2e/run-e2e.spec.ts +18 -19
  250. package/test/errors/errors.spec.ts +10 -0
  251. package/test/helpers/matter-harness.ts +20 -9
  252. package/test/homebridge-ui/server.spec.ts +9 -0
  253. package/test/platform/accessory-restore.spec.ts +37 -0
  254. package/test/platform/matter-childbridge.spec.ts +34 -0
  255. package/test/platform/matter-integration.spec.ts +33 -0
  256. package/test/platform/platform-edge.spec.ts +73 -0
  257. package/test/platform/platform.integration.spec.ts +34 -0
  258. package/test/utils/utils-extra.spec.ts +10 -0
  259. package/test/utils/utils.spec.ts +53 -0
  260. package/todo/TODO.md +80 -0
  261. package/tsconfig.ui.json +11 -0
  262. package/.github/npm-version-script-esm.js +0 -97
  263. package/.github/workflows/beta-release.yml +0 -52
  264. package/dist/platform.d.ts +0 -35
  265. package/dist/platform.d.ts.map +0 -1
  266. package/dist/platform.js +0 -945
  267. package/dist/platform.js.map +0 -1
  268. package/src/platform.ts +0 -963
  269. package/test/accessory-restore.spec.ts +0 -73
  270. package/test/device-mapping.spec.ts +0 -37
  271. package/test/deviceFactory.spec.ts +0 -18
  272. package/test/fan-swing.spec.ts +0 -29
  273. package/test/lock-users.spec.ts +0 -44
  274. package/test/matter-childbridge.spec.ts +0 -55
  275. package/test/matter-descriptors.spec.ts +0 -97
  276. package/test/matter-device-state.spec.ts +0 -101
  277. package/test/matter-integration.spec.ts +0 -70
  278. package/test/platform.integration.spec.ts +0 -55
  279. package/test/switchbot-client-debounce.spec.ts +0 -131
  280. package/test/switchbot-client-openapi.spec.ts +0 -56
  281. package/test/switchbotClient.spec.ts +0 -10
  282. package/test/utils.spec.ts +0 -20
package/src/utils.ts CHANGED
@@ -1,7 +1,1015 @@
1
- import type { PlatformConfig } from 'homebridge'
2
1
  import type { SwitchBotPluginConfig } from './settings.js'
2
+ import type { Logger, PlatformConfig } from 'homebridge'
3
+ /**
4
+ * Indicates which device types should prefer Matter if available.
5
+ * Based on HAP service mappings: device implementations use specific HomeKit services
6
+ * that map to corresponding Matter clusters when Matter is enabled.
7
+ *
8
+ * @property {boolean} [deviceType] - True if the device type supports Matter, false otherwise.
9
+ * @example
10
+ * DEVICE_MATTER_SUPPORTED['bot'] // true
11
+ */
12
+ /**
13
+ * Factory function to create Matter handlers with Homebridge logger integration.
14
+ * Returns handler objects for supported device types, mapping Matter cluster actions to SwitchBot API calls.
15
+ *
16
+ * @param log - Homebridge logger instance
17
+ * @param deviceId - SwitchBot device ID
18
+ * @param type - Device type string
19
+ * @param client - SwitchBot client instance
20
+ * @returns Handler object for Matter clusters
21
+ */
3
22
 
4
- // Canonical Matter cluster ID mapping (from matter.js clusters)
23
+ export const DEVICE_MATTER_SUPPORTED: Record<string, boolean> = {
24
+ // Core devices
25
+ 'bot': true, // Switch → OnOff
26
+ 'curtain': true, // WindowCovering → WindowCovering
27
+ 'fan': true, // Fan → FanControl
28
+ 'light': true, // Lightbulb → OnOff + LevelControl
29
+ 'lightstrip': true, // Lightbulb (color) → OnOff + LevelControl + ColorControl
30
+ 'motion': true, // MotionSensor → OccupancySensing
31
+ 'contact': true, // ContactSensor → BooleanState
32
+ 'vacuum': true, // Switch → RobotVacuumCleaner
33
+ 'lock': true, // LockMechanism → DoorLock
34
+ 'humidifier': true, // Fan + Humidity → OnOff + FanControl + RelativeHumidityMeasurement
35
+ 'temperature': true, // TemperatureSensor → TemperatureMeasurement
36
+
37
+ // Switch devices
38
+ 'relay': true, // Switch → OnOff
39
+ 'relay switch 1': true, // Switch → OnOff
40
+ 'relay switch 1pm': true, // Switch → OnOff
41
+ 'plug': true, // Outlet → OnOff
42
+ 'plug mini (jp)': true, // Outlet → OnOff
43
+ 'plug mini (us)': true, // Outlet → OnOff
44
+
45
+ // Window covering variants
46
+ 'blindtilt': true, // WindowCovering → WindowCovering
47
+ 'blind tilt': true, // WindowCovering → WindowCovering
48
+ 'curtain3': true, // WindowCovering → WindowCovering
49
+ 'rollershade': true, // WindowCovering → WindowCovering
50
+ 'roller shade': true, // WindowCovering → WindowCovering
51
+ 'worollershade': true, // WindowCovering → WindowCovering
52
+ 'wo rollershade': true, // WindowCovering → WindowCovering
53
+
54
+ // Vacuum variants (normalized to 'vacuum' before lookup)
55
+ 'wosweeper': true, // VacuumDevice → RobotVacuumCleaner
56
+ 'wosweepermini': true, // VacuumDevice → RobotVacuumCleaner
57
+ 'wosweeperminipro': true, // VacuumDevice → RobotVacuumCleaner
58
+ 'k10+': true, // VacuumDevice → RobotVacuumCleaner
59
+ 'k10+ pro': true, // VacuumDevice → RobotVacuumCleaner
60
+
61
+ // Sensors
62
+ 'meter': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
63
+ 'meterplus': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
64
+ 'meter plus (jp)': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
65
+ 'meterpro': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
66
+ 'meterpro(co2)': true, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
67
+ 'waterdetector': true, // LeakSensor → BooleanState
68
+ 'water detector': true, // LeakSensor → BooleanState
69
+
70
+ // Other devices
71
+ 'smart fan': true, // Fan → FanControl
72
+ 'strip light': true, // Lightbulb (color) → OnOff + LevelControl + ColorControl
73
+ 'hub 2': false, // Hub device - not exposed as accessory
74
+ 'walletfinder': false, // Button device - Matter support TBD
75
+ }
76
+
77
+ /**
78
+ * Default Matter cluster configurations by device type.
79
+ * Maps device types to their Matter cluster states (used when device doesn't provide clusters).
80
+ * Note: wosweeper/curtain/plug variants are normalized before cluster lookup (see loadDevices).
81
+ *
82
+ * @property {object} [deviceType] - The default cluster state for the device type.
83
+ * @example
84
+ * DEVICE_MATTER_CLUSTERS['bot'] // { onOff: { onOff: false } }
85
+ */
86
+ export const DEVICE_MATTER_CLUSTERS: Record<string, any> = {
87
+ // Core devices - aligned with HAP service implementations
88
+ bot: { onOff: { onOff: false } }, // Switch → OnOff
89
+ vacuum: {
90
+ rvcRunMode: {
91
+ supportedModes: [
92
+ { label: 'Idle', mode: 0, modeTags: [{ value: 16384 }] },
93
+ { label: 'Cleaning', mode: 1, modeTags: [{ value: 16385 }] },
94
+ ],
95
+ currentMode: 0,
96
+ },
97
+ rvcCleanMode: {
98
+ supportedModes: [
99
+ { label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] },
100
+ ],
101
+ currentMode: 0,
102
+ },
103
+ rvcOperationalState: {
104
+ operationalStateList: [
105
+ { operationalStateId: 0 }, // Stopped
106
+ { operationalStateId: 1 }, // Running
107
+ { operationalStateId: 2 }, // Paused
108
+ { operationalStateId: 3 }, // Error (required)
109
+ { operationalStateId: 64 }, // Seeking charger
110
+ { operationalStateId: 65 }, // Charging
111
+ { operationalStateId: 66 }, // Docked
112
+ ],
113
+ operationalState: 66,
114
+ },
115
+ }, // Switch in HAP, RobotVacuumCleaner in Matter
116
+ curtain: {
117
+ windowCovering: {
118
+ currentPositionLiftPercent100ths: 0,
119
+ targetPositionLiftPercent100ths: 0,
120
+ operationalStatus: {
121
+ global: 0,
122
+ lift: 0,
123
+ tilt: 0,
124
+ },
125
+ endProductType: 0,
126
+ configStatus: {
127
+ operational: true,
128
+ onlineReserved: true,
129
+ liftMovementReversed: false,
130
+ liftPositionAware: true,
131
+ tiltPositionAware: false,
132
+ liftEncoderControlled: true,
133
+ tiltEncoderControlled: false,
134
+ },
135
+ },
136
+ }, // WindowCovering → WindowCovering (includes curtain3, rollershade variants via normalization)
137
+ blindtilt: {
138
+ windowCovering: {
139
+ currentPositionLiftPercent100ths: 0,
140
+ targetPositionLiftPercent100ths: 0,
141
+ currentPositionTiltPercent100ths: 0,
142
+ targetPositionTiltPercent100ths: 0,
143
+ operationalStatus: {
144
+ global: 0,
145
+ lift: 0,
146
+ tilt: 0,
147
+ },
148
+ endProductType: 8,
149
+ configStatus: {
150
+ operational: true,
151
+ onlineReserved: true,
152
+ liftMovementReversed: false,
153
+ liftPositionAware: true,
154
+ tiltPositionAware: true,
155
+ liftEncoderControlled: true,
156
+ tiltEncoderControlled: true,
157
+ },
158
+ },
159
+ }, // WindowCovering with tilt → WindowCovering
160
+ fan: {
161
+ onOff: { onOff: false },
162
+ fanControl: {
163
+ fanMode: 0,
164
+ percentCurrent: 0,
165
+ percentSetting: 0,
166
+ speedCurrent: 0,
167
+ speedMax: 100,
168
+ },
169
+ }, // Fan → OnOff + FanControl
170
+ light: {
171
+ onOff: { onOff: false },
172
+ levelControl: {
173
+ currentLevel: 0,
174
+ minLevel: 0,
175
+ maxLevel: 254,
176
+ },
177
+ }, // Lightbulb → OnOff + LevelControl
178
+ lightstrip: {
179
+ onOff: { onOff: false },
180
+ levelControl: {
181
+ currentLevel: 0,
182
+ minLevel: 0,
183
+ maxLevel: 254,
184
+ },
185
+ colorControl: {
186
+ colorMode: 0,
187
+ },
188
+ }, // Lightbulb with color → OnOff + LevelControl + ColorControl
189
+ lock: {
190
+ doorLock: {
191
+ lockState: 0,
192
+ lockType: 0,
193
+ actuatorEnabled: true,
194
+ operatingMode: 0,
195
+ },
196
+ }, // LockMechanism → DoorLock
197
+ motion: {
198
+ occupancySensing: {
199
+ occupancy: 0,
200
+ occupancySensorType: 0,
201
+ },
202
+ }, // MotionSensor → OccupancySensing
203
+ contact: {
204
+ booleanState: {
205
+ stateValue: false,
206
+ },
207
+ }, // ContactSensor → BooleanState
208
+ humidifier: {
209
+ onOff: { onOff: false },
210
+ fanControl: {
211
+ fanMode: 0,
212
+ percentCurrent: 0,
213
+ },
214
+ relativeHumidityMeasurement: {
215
+ measuredValue: 0,
216
+ minMeasuredValue: 0,
217
+ maxMeasuredValue: 100,
218
+ },
219
+ }, // HumidifierDehumidifier → OnOff + FanControl + RelativeHumidityMeasurement
220
+ temperature: {
221
+ temperatureMeasurement: {
222
+ measuredValue: 0,
223
+ minMeasuredValue: -27315,
224
+ maxMeasuredValue: 32767,
225
+ },
226
+ }, // TemperatureSensor → TemperatureMeasurement
227
+
228
+ // Switch/Outlet devices
229
+ relay: { onOff: { onOff: false } }, // Switch → OnOff
230
+ plug: {
231
+ onOff: { onOff: false },
232
+ electricalMeasurement: {
233
+ activePower: 0,
234
+ rmsCurrent: 0,
235
+ rmsVoltage: 0,
236
+ },
237
+ }, // Outlet → OnOff + ElectricalMeasurement (for PM models)
238
+
239
+ // Sensors
240
+ meter: {
241
+ temperatureMeasurement: {
242
+ measuredValue: 0,
243
+ minMeasuredValue: -27315,
244
+ maxMeasuredValue: 32767,
245
+ },
246
+ relativeHumidityMeasurement: {
247
+ measuredValue: 0,
248
+ minMeasuredValue: 0,
249
+ maxMeasuredValue: 100,
250
+ },
251
+ }, // TemperatureSensor + HumiditySensor → TemperatureMeasurement + RelativeHumidityMeasurement
252
+ waterdetector: {
253
+ booleanState: {
254
+ stateValue: false,
255
+ },
256
+ }, // LeakSensor → BooleanState
257
+ }
258
+
259
+ export function createMatterHandlers(log: Logger, deviceId: string, type: string, client: any): any {
260
+ const lowerType = type.toLowerCase()
261
+
262
+ switch (lowerType) {
263
+ case 'vacuum':
264
+ return {
265
+ rvcRunMode: {
266
+ changeToMode: async (request: any) => {
267
+ const modeNames = ['Idle', 'Cleaning', 'Mapping']
268
+ const modeName = modeNames[request?.newMode] || `Unknown (${request?.newMode})`
269
+ log.info(`[${deviceId}] RVC run mode change requested: ${modeName}`)
270
+ if (!client) {
271
+ log.warn(`[${deviceId}] No SwitchBot client available`)
272
+ return { success: false }
273
+ }
274
+ try {
275
+ // For K10+ family: use 'start' to begin cleaning (mode 1 = Cleaning)
276
+ // For older K10+: only supports start/stop/dock
277
+ // For newer K20+/S10/S20: supports startClean with more parameters
278
+ // Map Matter mode to SwitchBot command
279
+ const switchBotCommand = request?.newMode === 1 ? 'start' : 'stop'
280
+ const body = {
281
+ command: switchBotCommand,
282
+ parameter: 'default',
283
+ commandType: 'command',
284
+ }
285
+ log.debug(`[${deviceId}] Sending RVC mode change request:`, JSON.stringify(body))
286
+ const result = await client.setDeviceState(deviceId, body)
287
+ log.debug(`[${deviceId}] RVC mode change API response:`, JSON.stringify(result))
288
+ log.info(`[${deviceId}] RVC mode changed successfully to ${switchBotCommand}`)
289
+ return { success: true, result }
290
+ } catch (e) {
291
+ log.error(`[${deviceId}] Failed to change RVC mode:`, e)
292
+ return { success: false, error: e }
293
+ }
294
+ },
295
+ },
296
+ rvcCleanMode: {
297
+ changeToMode: async (request: any) => {
298
+ const modeName = request?.newMode !== undefined ? `Mode ${request.newMode}` : 'Unknown'
299
+ log.info(`[${deviceId}] RVC clean mode change requested: ${modeName}`)
300
+ if (!client) {
301
+ log.warn(`[${deviceId}] No SwitchBot client available`)
302
+ return { success: false }
303
+ }
304
+ try {
305
+ // Clean mode (vacuum/mop/etc) not directly supported via Matter for K10+
306
+ // K20+ Pro and newer models support via startClean action parameter
307
+ log.info(`[${deviceId}] Clean mode change requires startClean command (not yet implemented for Matter)`)
308
+ return { success: true }
309
+ } catch (e) {
310
+ log.error(`[${deviceId}] Failed to change RVC clean mode:`, e)
311
+ return { success: false, error: e }
312
+ }
313
+ },
314
+ },
315
+ rvcOperationalState: {
316
+ pause: async () => {
317
+ log.info(`[${deviceId}] RVC pause command received`)
318
+ if (!client) {
319
+ log.warn(`[${deviceId}] No SwitchBot client available`)
320
+ return { success: false }
321
+ }
322
+ try {
323
+ const body = {
324
+ command: 'stop',
325
+ parameter: 'default',
326
+ commandType: 'command',
327
+ }
328
+ log.debug(`[${deviceId}] Sending RVC pause request:`, JSON.stringify(body))
329
+ const result = await client.setDeviceState(deviceId, body)
330
+ log.debug(`[${deviceId}] RVC pause API response:`, JSON.stringify(result))
331
+ log.info(`[${deviceId}] RVC paused successfully`)
332
+ return { success: true, result }
333
+ } catch (e) {
334
+ log.error(`[${deviceId}] Failed to pause RVC:`, e)
335
+ return { success: false, error: e }
336
+ }
337
+ },
338
+ resume: async () => {
339
+ log.info(`[${deviceId}] RVC resume command received`)
340
+ if (!client) {
341
+ log.warn(`[${deviceId}] No SwitchBot client available`)
342
+ return { success: false }
343
+ }
344
+ try {
345
+ const body = {
346
+ command: 'start',
347
+ parameter: 'default',
348
+ commandType: 'command',
349
+ }
350
+ log.debug(`[${deviceId}] Sending RVC resume request:`, JSON.stringify(body))
351
+ const result = await client.setDeviceState(deviceId, body)
352
+ log.debug(`[${deviceId}] RVC resume API response:`, JSON.stringify(result))
353
+ log.info(`[${deviceId}] RVC resumed successfully`)
354
+ return { success: true, result }
355
+ } catch (e) {
356
+ log.error(`[${deviceId}] Failed to resume RVC:`, e)
357
+ return { success: false, error: e }
358
+ }
359
+ },
360
+ goHome: async () => {
361
+ log.info(`[${deviceId}] RVC goHome command received`)
362
+ if (!client) {
363
+ log.warn(`[${deviceId}] No SwitchBot client available`)
364
+ return { success: false }
365
+ }
366
+ try {
367
+ const body = {
368
+ command: 'dock',
369
+ parameter: 'default',
370
+ commandType: 'command',
371
+ }
372
+ log.debug(`[${deviceId}] Sending RVC goHome request:`, JSON.stringify(body))
373
+ const result = await client.setDeviceState(deviceId, body)
374
+ log.debug(`[${deviceId}] RVC goHome API response:`, JSON.stringify(result))
375
+ log.info(`[${deviceId}] RVC sent to dock successfully`)
376
+ return { success: true, result }
377
+ } catch (e) {
378
+ log.error(`[${deviceId}] Failed to send goHome command:`, e)
379
+ return { success: false, error: e }
380
+ }
381
+ },
382
+ },
383
+ }
384
+
385
+ case 'bot':
386
+ return {
387
+ onOff: {
388
+ on: async () => {
389
+ log.info(`[${deviceId}] Bot ON command received`)
390
+ if (!client) {
391
+ log.warn(`[${deviceId}] No SwitchBot client available`)
392
+ return { success: false }
393
+ }
394
+ try {
395
+ const result = await client.setDeviceState(deviceId, {
396
+ command: 'turnOn',
397
+ parameter: 'default',
398
+ commandType: 'command',
399
+ })
400
+ log.info(`[${deviceId}] Bot turned on successfully`)
401
+ return { success: true, result }
402
+ } catch (e) {
403
+ log.error(`[${deviceId}] Failed to turn on Bot:`, e)
404
+ return { success: false, error: e }
405
+ }
406
+ },
407
+ off: async () => {
408
+ log.info(`[${deviceId}] Bot OFF command received`)
409
+ if (!client) {
410
+ log.warn(`[${deviceId}] No SwitchBot client available`)
411
+ return { success: false }
412
+ }
413
+ try {
414
+ const result = await client.setDeviceState(deviceId, {
415
+ command: 'turnOff',
416
+ parameter: 'default',
417
+ commandType: 'command',
418
+ })
419
+ log.info(`[${deviceId}] Bot turned off successfully`)
420
+ return { success: true, result }
421
+ } catch (e) {
422
+ log.error(`[${deviceId}] Failed to turn off Bot:`, e)
423
+ return { success: false, error: e }
424
+ }
425
+ },
426
+ },
427
+ }
428
+
429
+ case 'curtain':
430
+ case 'blindtilt':
431
+ return {
432
+ windowCovering: {
433
+ goToLiftPercentage: async (request: any) => {
434
+ const percentage = request?.liftPercent100thsValue
435
+ log.info(`[${deviceId}] Curtain position change requested: ${percentage}`)
436
+ if (!client) {
437
+ log.warn(`[${deviceId}] No SwitchBot client available`)
438
+ return { success: false }
439
+ }
440
+ try {
441
+ // Convert Matter percentage (0-10000) to SwitchBot (0-100)
442
+ const position = Math.max(0, Math.min(100, Math.round((percentage || 0) / 100)))
443
+ const result = await client.setDeviceState(deviceId, {
444
+ command: 'setPosition',
445
+ parameter: String(position),
446
+ commandType: 'command',
447
+ })
448
+ log.info(`[${deviceId}] Curtain position set to ${position}% successfully`)
449
+ return { success: true, result }
450
+ } catch (e) {
451
+ log.error(`[${deviceId}] Failed to set curtain position:`, e)
452
+ return { success: false, error: e }
453
+ }
454
+ },
455
+ upOrOpen: async () => {
456
+ log.info(`[${deviceId}] Curtain open command received`)
457
+ if (!client) {
458
+ log.warn(`[${deviceId}] No SwitchBot client available`)
459
+ return { success: false }
460
+ }
461
+ try {
462
+ const result = await client.setDeviceState(deviceId, {
463
+ command: 'open',
464
+ parameter: 'default',
465
+ commandType: 'command',
466
+ })
467
+ log.info(`[${deviceId}] Curtain opened successfully`)
468
+ return { success: true, result }
469
+ } catch (e) {
470
+ log.error(`[${deviceId}] Failed to open curtain:`, e)
471
+ return { success: false, error: e }
472
+ }
473
+ },
474
+ downOrClose: async () => {
475
+ log.info(`[${deviceId}] Curtain close command received`)
476
+ if (!client) {
477
+ log.warn(`[${deviceId}] No SwitchBot client available`)
478
+ return { success: false }
479
+ }
480
+ try {
481
+ const result = await client.setDeviceState(deviceId, {
482
+ command: 'close',
483
+ parameter: 'default',
484
+ commandType: 'command',
485
+ })
486
+ log.info(`[${deviceId}] Curtain closed successfully`)
487
+ return { success: true, result }
488
+ } catch (e) {
489
+ log.error(`[${deviceId}] Failed to close curtain:`, e)
490
+ return { success: false, error: e }
491
+ }
492
+ },
493
+ stopMotion: async () => {
494
+ log.info(`[${deviceId}] Curtain stop command received`)
495
+ if (!client) {
496
+ log.warn(`[${deviceId}] No SwitchBot client available`)
497
+ return { success: false }
498
+ }
499
+ try {
500
+ const result = await client.setDeviceState(deviceId, {
501
+ command: 'pause',
502
+ parameter: 'default',
503
+ commandType: 'command',
504
+ })
505
+ log.info(`[${deviceId}] Curtain motion stopped successfully`)
506
+ return { success: true, result }
507
+ } catch (e) {
508
+ log.error(`[${deviceId}] Failed to stop curtain:`, e)
509
+ return { success: false, error: e }
510
+ }
511
+ },
512
+ },
513
+ }
514
+
515
+ case 'plug':
516
+ return {
517
+ onOff: {
518
+ on: async () => {
519
+ log.info(`[${deviceId}] Plug ON command received`)
520
+ if (!client) {
521
+ log.warn(`[${deviceId}] No SwitchBot client available`)
522
+ return { success: false }
523
+ }
524
+ try {
525
+ const result = await client.setDeviceState(deviceId, {
526
+ command: 'turnOn',
527
+ parameter: 'default',
528
+ commandType: 'command',
529
+ })
530
+ log.info(`[${deviceId}] Plug turned on successfully`)
531
+ return { success: true, result }
532
+ } catch (e) {
533
+ log.error(`[${deviceId}] Failed to turn on plug:`, e)
534
+ return { success: false, error: e }
535
+ }
536
+ },
537
+ off: async () => {
538
+ log.info(`[${deviceId}] Plug OFF command received`)
539
+ if (!client) {
540
+ log.warn(`[${deviceId}] No SwitchBot client available`)
541
+ return { success: false }
542
+ }
543
+ try {
544
+ const result = await client.setDeviceState(deviceId, {
545
+ command: 'turnOff',
546
+ parameter: 'default',
547
+ commandType: 'command',
548
+ })
549
+ log.info(`[${deviceId}] Plug turned off successfully`)
550
+ return { success: true, result }
551
+ } catch (e) {
552
+ log.error(`[${deviceId}] Failed to turn off plug:`, e)
553
+ return { success: false, error: e }
554
+ }
555
+ },
556
+ },
557
+ }
558
+
559
+ case 'lock':
560
+ return {
561
+ doorLock: {
562
+ setLockState: async (request: any) => {
563
+ const state = request?.lockState === 1 ? 'LOCKED' : 'UNLOCKED'
564
+ log.info(`[${deviceId}] Lock state change requested: ${state}`)
565
+ if (!client) {
566
+ log.warn(`[${deviceId}] No SwitchBot client available`)
567
+ return { success: false }
568
+ }
569
+ try {
570
+ const command = request?.lockState === 1 ? 'lock' : 'unlock'
571
+ const result = await client.setDeviceState(deviceId, {
572
+ command,
573
+ parameter: 'default',
574
+ commandType: 'command',
575
+ })
576
+ log.info(`[${deviceId}] Lock ${state} successfully`)
577
+ return { success: true, result }
578
+ } catch (e) {
579
+ log.error(`[${deviceId}] Failed to change lock state:`, e)
580
+ return { success: false, error: e }
581
+ }
582
+ },
583
+ },
584
+ }
585
+
586
+ case 'fan':
587
+ return {
588
+ onOff: {
589
+ on: async () => {
590
+ log.info(`[${deviceId}] Fan ON command received`)
591
+ if (!client) {
592
+ log.warn(`[${deviceId}] No SwitchBot client available`)
593
+ return { success: false }
594
+ }
595
+ try {
596
+ const result = await client.setDeviceState(deviceId, {
597
+ command: 'turnOn',
598
+ parameter: 'default',
599
+ commandType: 'command',
600
+ })
601
+ log.info(`[${deviceId}] Fan turned on successfully`)
602
+ return { success: true, result }
603
+ } catch (e) {
604
+ log.error(`[${deviceId}] Failed to turn on fan:`, e)
605
+ return { success: false, error: e }
606
+ }
607
+ },
608
+ off: async () => {
609
+ log.info(`[${deviceId}] Fan OFF command received`)
610
+ if (!client) {
611
+ log.warn(`[${deviceId}] No SwitchBot client available`)
612
+ return { success: false }
613
+ }
614
+ try {
615
+ const result = await client.setDeviceState(deviceId, {
616
+ command: 'turnOff',
617
+ parameter: 'default',
618
+ commandType: 'command',
619
+ })
620
+ log.info(`[${deviceId}] Fan turned off successfully`)
621
+ return { success: true, result }
622
+ } catch (e) {
623
+ log.error(`[${deviceId}] Failed to turn off fan:`, e)
624
+ return { success: false, error: e }
625
+ }
626
+ },
627
+ },
628
+ fanControl: {
629
+ setFanSpeed: async (request: any) => {
630
+ const speed = request?.percentSetting || 0
631
+ log.info(`[${deviceId}] Fan speed change requested: ${speed}%`)
632
+ if (!client) {
633
+ log.warn(`[${deviceId}] No SwitchBot client available`)
634
+ return { success: false }
635
+ }
636
+ try {
637
+ // Convert percentage to SwitchBot fan speed parameter
638
+ const speedParam = Math.max(1, Math.min(100, speed))
639
+ const result = await client.setDeviceState(deviceId, {
640
+ command: 'setFanSpeed',
641
+ parameter: String(speedParam),
642
+ commandType: 'command',
643
+ })
644
+ log.info(`[${deviceId}] Fan speed set to ${speedParam}% successfully`)
645
+ return { success: true, result }
646
+ } catch (e) {
647
+ log.error(`[${deviceId}] Failed to set fan speed:`, e)
648
+ return { success: false, error: e }
649
+ }
650
+ },
651
+ },
652
+ }
653
+
654
+ case 'light':
655
+ return {
656
+ onOff: {
657
+ on: async () => {
658
+ log.info(`[${deviceId}] Light ON command received`)
659
+ if (!client) {
660
+ log.warn(`[${deviceId}] No SwitchBot client available`)
661
+ return { success: false }
662
+ }
663
+ try {
664
+ const result = await client.setDeviceState(deviceId, {
665
+ command: 'turnOn',
666
+ parameter: 'default',
667
+ commandType: 'command',
668
+ })
669
+ log.info(`[${deviceId}] Light turned on successfully`)
670
+ return { success: true, result }
671
+ } catch (e) {
672
+ log.error(`[${deviceId}] Failed to turn on light:`, e)
673
+ return { success: false, error: e }
674
+ }
675
+ },
676
+ off: async () => {
677
+ log.info(`[${deviceId}] Light OFF command received`)
678
+ if (!client) {
679
+ log.warn(`[${deviceId}] No SwitchBot client available`)
680
+ return { success: false }
681
+ }
682
+ try {
683
+ const result = await client.setDeviceState(deviceId, {
684
+ command: 'turnOff',
685
+ parameter: 'default',
686
+ commandType: 'command',
687
+ })
688
+ log.info(`[${deviceId}] Light turned off successfully`)
689
+ return { success: true, result }
690
+ } catch (e) {
691
+ log.error(`[${deviceId}] Failed to turn off light:`, e)
692
+ return { success: false, error: e }
693
+ }
694
+ },
695
+ },
696
+ levelControl: {
697
+ moveToLevel: async (request: any) => {
698
+ const level = request?.level || 0
699
+ // Convert from 0-254 to 0-100
700
+ const brightness = Math.round((level / 254) * 100)
701
+ log.info(`[${deviceId}] Light brightness change requested: ${brightness}%`)
702
+ if (!client) {
703
+ log.warn(`[${deviceId}] No SwitchBot client available`)
704
+ return { success: false }
705
+ }
706
+ try {
707
+ const param = Math.max(0, Math.min(100, brightness))
708
+ const result = await client.setDeviceState(deviceId, {
709
+ command: 'setBrightness',
710
+ parameter: String(param),
711
+ commandType: 'command',
712
+ })
713
+ log.info(`[${deviceId}] Light brightness set to ${param}% successfully`)
714
+ return { success: true, result }
715
+ } catch (e) {
716
+ log.error(`[${deviceId}] Failed to set light brightness:`, e)
717
+ return { success: false, error: e }
718
+ }
719
+ },
720
+ },
721
+ }
722
+
723
+ case 'lightstrip':
724
+ return {
725
+ onOff: {
726
+ on: async () => {
727
+ log.info(`[${deviceId}] Lightstrip ON command received`)
728
+ if (!client) {
729
+ log.warn(`[${deviceId}] No SwitchBot client available`)
730
+ return { success: false }
731
+ }
732
+ try {
733
+ const result = await client.setDeviceState(deviceId, {
734
+ command: 'turnOn',
735
+ parameter: 'default',
736
+ commandType: 'command',
737
+ })
738
+ log.info(`[${deviceId}] Lightstrip turned on successfully`)
739
+ return { success: true, result }
740
+ } catch (e) {
741
+ log.error(`[${deviceId}] Failed to turn on lightstrip:`, e)
742
+ return { success: false, error: e }
743
+ }
744
+ },
745
+ off: async () => {
746
+ log.info(`[${deviceId}] Lightstrip OFF command received`)
747
+ if (!client) {
748
+ log.warn(`[${deviceId}] No SwitchBot client available`)
749
+ return { success: false }
750
+ }
751
+ try {
752
+ const result = await client.setDeviceState(deviceId, {
753
+ command: 'turnOff',
754
+ parameter: 'default',
755
+ commandType: 'command',
756
+ })
757
+ log.info(`[${deviceId}] Lightstrip turned off successfully`)
758
+ return { success: true, result }
759
+ } catch (e) {
760
+ log.error(`[${deviceId}] Failed to turn off lightstrip:`, e)
761
+ return { success: false, error: e }
762
+ }
763
+ },
764
+ },
765
+ levelControl: {
766
+ moveToLevel: async (request: any) => {
767
+ const level = request?.level || 0
768
+ // Convert from 0-254 to 0-100
769
+ const brightness = Math.round((level / 254) * 100)
770
+ log.info(`[${deviceId}] Lightstrip brightness change requested: ${brightness}%`)
771
+ if (!client) {
772
+ log.warn(`[${deviceId}] No SwitchBot client available`)
773
+ return { success: false }
774
+ }
775
+ try {
776
+ const param = Math.max(0, Math.min(100, brightness))
777
+ const result = await client.setDeviceState(deviceId, {
778
+ command: 'setBrightness',
779
+ parameter: String(param),
780
+ commandType: 'command',
781
+ })
782
+ log.info(`[${deviceId}] Lightstrip brightness set to ${param}% successfully`)
783
+ return { success: true, result }
784
+ } catch (e) {
785
+ log.error(`[${deviceId}] Failed to set lightstrip brightness:`, e)
786
+ return { success: false, error: e }
787
+ }
788
+ },
789
+ },
790
+ colorControl: {
791
+ moveToHueAndSaturation: async (request: any) => {
792
+ const hue = request?.hue || 0
793
+ const saturation = request?.saturation || 0
794
+ log.info(`[${deviceId}] Lightstrip color change requested: hue=${hue}, sat=${saturation}`)
795
+ if (!client) {
796
+ log.warn(`[${deviceId}] No SwitchBot client available`)
797
+ return { success: false }
798
+ }
799
+ try {
800
+ // Convert hue (0-254) and saturation (0-254) to combined color parameter
801
+ // SwitchBot typically expects RGB or HSV format as parameter
802
+ const colorParam = `${Math.round(hue)},${Math.round(saturation)}`
803
+ const result = await client.setDeviceState(deviceId, {
804
+ command: 'setColor',
805
+ parameter: colorParam,
806
+ commandType: 'command',
807
+ })
808
+ log.info(`[${deviceId}] Lightstrip color set successfully`)
809
+ return { success: true, result }
810
+ } catch (e) {
811
+ log.error(`[${deviceId}] Failed to set lightstrip color:`, e)
812
+ return { success: false, error: e }
813
+ }
814
+ },
815
+ moveToColorTemperature: async (request: any) => {
816
+ const mireds = request?.colorTemperatureMireds || 400
817
+ // Convert mireds (158-500 typical range) to Kelvin: K = 1000000 / mireds
818
+ const kelvin = Math.round(1000000 / mireds)
819
+ log.info(`[${deviceId}] Lightstrip color temperature change requested: ${mireds} mireds (${kelvin}K)`)
820
+ if (!client) {
821
+ log.warn(`[${deviceId}] No SwitchBot client available`)
822
+ return { success: false }
823
+ }
824
+ try {
825
+ // Map Kelvin to SwitchBot color temperature parameter (typically 0-100 or specific values)
826
+ // Normalize to 0-100 scale where 0=warm (2700K) and 100=cool (6500K)
827
+ const colorTempParam = Math.max(0, Math.min(100, Math.round(((kelvin - 2700) / 3800) * 100)))
828
+ const result = await client.setDeviceState(deviceId, {
829
+ command: 'setColorTemperature',
830
+ parameter: String(colorTempParam),
831
+ commandType: 'command',
832
+ })
833
+ log.info(`[${deviceId}] Lightstrip color temperature set to ${kelvin}K successfully`)
834
+ return { success: true, result }
835
+ } catch (e) {
836
+ log.error(`[${deviceId}] Failed to set lightstrip color temperature:`, e)
837
+ return { success: false, error: e }
838
+ }
839
+ },
840
+ },
841
+ }
842
+
843
+ case 'humidifier':
844
+ return {
845
+ onOff: {
846
+ on: async () => {
847
+ log.info(`[${deviceId}] Humidifier ON command received`)
848
+ if (!client) {
849
+ log.warn(`[${deviceId}] No SwitchBot client available`)
850
+ return { success: false }
851
+ }
852
+ try {
853
+ const result = await client.setDeviceState(deviceId, {
854
+ command: 'turnOn',
855
+ parameter: 'default',
856
+ commandType: 'command',
857
+ })
858
+ log.info(`[${deviceId}] Humidifier turned on successfully`)
859
+ return { success: true, result }
860
+ } catch (e) {
861
+ log.error(`[${deviceId}] Failed to turn on humidifier:`, e)
862
+ return { success: false, error: e }
863
+ }
864
+ },
865
+ off: async () => {
866
+ log.info(`[${deviceId}] Humidifier OFF command received`)
867
+ if (!client) {
868
+ log.warn(`[${deviceId}] No SwitchBot client available`)
869
+ return { success: false }
870
+ }
871
+ try {
872
+ const result = await client.setDeviceState(deviceId, {
873
+ command: 'turnOff',
874
+ parameter: 'default',
875
+ commandType: 'command',
876
+ })
877
+ log.info(`[${deviceId}] Humidifier turned off successfully`)
878
+ return { success: true, result }
879
+ } catch (e) {
880
+ log.error(`[${deviceId}] Failed to turn off humidifier:`, e)
881
+ return { success: false, error: e }
882
+ }
883
+ },
884
+ },
885
+ fanControl: {
886
+ setFanSpeed: async (request: any) => {
887
+ const speed = request?.percentSetting || 0
888
+ log.info(`[${deviceId}] Humidifier speed change requested: ${speed}%`)
889
+ if (!client) {
890
+ log.warn(`[${deviceId}] No SwitchBot client available`)
891
+ return { success: false }
892
+ }
893
+ try {
894
+ // Convert percentage to SwitchBot humidifier speed parameter
895
+ const speedParam = Math.max(1, Math.min(100, speed))
896
+ const result = await client.setDeviceState(deviceId, {
897
+ command: 'setFanSpeed',
898
+ parameter: String(speedParam),
899
+ commandType: 'command',
900
+ })
901
+ log.info(`[${deviceId}] Humidifier speed set to ${speedParam}% successfully`)
902
+ return { success: true, result }
903
+ } catch (e) {
904
+ log.error(`[${deviceId}] Failed to set humidifier speed:`, e)
905
+ return { success: false, error: e }
906
+ }
907
+ },
908
+ },
909
+ }
910
+
911
+ case 'relay':
912
+ return {
913
+ onOff: {
914
+ on: async () => {
915
+ log.info(`[${deviceId}] Relay ON command received`)
916
+ if (!client) {
917
+ log.warn(`[${deviceId}] No SwitchBot client available`)
918
+ return { success: false }
919
+ }
920
+ try {
921
+ const result = await client.setDeviceState(deviceId, {
922
+ command: 'turnOn',
923
+ parameter: 'default',
924
+ commandType: 'command',
925
+ })
926
+ log.info(`[${deviceId}] Relay turned on successfully`)
927
+ return { success: true, result }
928
+ } catch (e) {
929
+ log.error(`[${deviceId}] Failed to turn on relay:`, e)
930
+ return { success: false, error: e }
931
+ }
932
+ },
933
+ off: async () => {
934
+ log.info(`[${deviceId}] Relay OFF command received`)
935
+ if (!client) {
936
+ log.warn(`[${deviceId}] No SwitchBot client available`)
937
+ return { success: false }
938
+ }
939
+ try {
940
+ const result = await client.setDeviceState(deviceId, {
941
+ command: 'turnOff',
942
+ parameter: 'default',
943
+ commandType: 'command',
944
+ })
945
+ log.info(`[${deviceId}] Relay turned off successfully`)
946
+ return { success: true, result }
947
+ } catch (e) {
948
+ log.error(`[${deviceId}] Failed to turn off relay:`, e)
949
+ return { success: false, error: e }
950
+ }
951
+ },
952
+ },
953
+ }
954
+
955
+ default:
956
+ return undefined
957
+ }
958
+ }
959
+
960
+ /**
961
+ * Resolves the Matter device type for a given device, using the Matter API and device type mappings.
962
+ * Used to map normalized device types to Matter device type definitions.
963
+ *
964
+ * @param matterApi - The Matter API object containing deviceTypes
965
+ * @param type - The normalized device type string
966
+ * @param createdDeviceType - Optionally, an already created device type object
967
+ * @param clusters - Optionally, the clusters object for the device
968
+ * @returns The resolved Matter device type object
969
+ */
970
+ const DEVICE_MATTER_DEVICE_TYPE_KEYS: Record<string, string> = {
971
+ bot: 'OnOffSwitch',
972
+ vacuum: 'RoboticVacuumCleaner',
973
+ curtain: 'WindowCovering',
974
+ blindtilt: 'WindowCovering',
975
+ fan: 'Fan',
976
+ light: 'DimmableLight',
977
+ lightstrip: 'ExtendedColorLight',
978
+ lock: 'DoorLock',
979
+ motion: 'MotionSensor',
980
+ contact: 'ContactSensor',
981
+ humidifier: 'Fan',
982
+ temperature: 'TemperatureSensor',
983
+ relay: 'OnOffSwitch',
984
+ plug: 'OnOffOutlet',
985
+ meter: 'TemperatureSensor',
986
+ waterdetector: 'LeakSensor',
987
+ }
988
+
989
+ export function resolveMatterDeviceType(matterApi: any, type: string, createdDeviceType?: any, clusters?: any): any {
990
+ if (createdDeviceType && typeof createdDeviceType === 'object' && typeof createdDeviceType.with === 'function') {
991
+ return createdDeviceType
992
+ }
993
+
994
+ const lowerType = (typeof createdDeviceType === 'string' && createdDeviceType) ? createdDeviceType.toLowerCase() : (type || '').toLowerCase()
995
+
996
+ // Cluster-based upgrade for color lights if descriptor omitted device type.
997
+ const hasColorControl = !!clusters?.colorControl
998
+ const inferredType = hasColorControl && lowerType === 'light'
999
+ ? 'lightstrip'
1000
+ : lowerType
1001
+
1002
+ const mappedKey = DEVICE_MATTER_DEVICE_TYPE_KEYS[inferredType] || 'OnOffSwitch'
1003
+ return matterApi?.deviceTypes?.[mappedKey] || matterApi?.deviceTypes?.OnOffSwitch
1004
+ }
1005
+
1006
+ /**
1007
+ * Canonical Matter cluster ID mapping (from matter.js clusters).
1008
+ * Maps cluster names to their numeric cluster IDs.
1009
+ *
1010
+ * @example
1011
+ * MATTER_CLUSTER_IDS.OnOff // 0x0006
1012
+ */
5
1013
  export const MATTER_CLUSTER_IDS = {
6
1014
  OnOff: 0x0006,
7
1015
  LevelControl: 0x0008,
@@ -12,7 +1020,13 @@ export const MATTER_CLUSTER_IDS = {
12
1020
  RelativeHumidityMeasurement: 0x0405,
13
1021
  } as const
14
1022
 
15
- // Common Matter attribute IDs grouped by cluster
1023
+ /**
1024
+ * Common Matter attribute IDs grouped by cluster.
1025
+ * Maps cluster names to objects mapping attribute names to their numeric attribute IDs.
1026
+ *
1027
+ * @example
1028
+ * MATTER_ATTRIBUTE_IDS.OnOff.OnOff // 0x0000
1029
+ */
16
1030
  export const MATTER_ATTRIBUTE_IDS = {
17
1031
  OnOff: { OnOff: 0x0000 },
18
1032
  LevelControl: { CurrentLevel: 0x0000 },
@@ -23,21 +1037,135 @@ export const MATTER_ATTRIBUTE_IDS = {
23
1037
  RelativeHumidityMeasurement: { MeasuredValue: 0x0000 },
24
1038
  } as const
25
1039
 
1040
+ /**
1041
+ * Normalizes a device type string for Matter integration.
1042
+ * Maps various device type aliases and variants to canonical Matter device types.
1043
+ *
1044
+ * @param {string | undefined | null} typeValue - The device type string to normalize.
1045
+ * @returns {string} The normalized device type string for Matter.
1046
+ *
1047
+ * @example
1048
+ * normalizeTypeForMatter('wosweeper') // 'vacuum'
1049
+ * normalizeTypeForMatter('curtain3') // 'curtain'
1050
+ * normalizeTypeForMatter('plug mini (us)') // 'plug'
1051
+ */
1052
+ export function normalizeTypeForMatter(typeValue: string | undefined | null): string {
1053
+ const raw = String(typeValue || '').trim().toLowerCase()
1054
+ if (!raw) {
1055
+ return 'unknown'
1056
+ }
1057
+
1058
+ // Vacuum variants
1059
+ if (['wosweeper', 'wosweepermini', 'wosweeperminipro', 'k10+', 'k10+ pro'].includes(raw)) {
1060
+ return 'vacuum'
1061
+ }
1062
+
1063
+ // Window covering variants
1064
+ if (['curtain', 'curtain3', 'rollershade', 'roller shade', 'worollershade', 'wo rollershade'].includes(raw)) {
1065
+ return 'curtain'
1066
+ }
1067
+
1068
+ // Blind tilt variants (normalized to 'blindtilt' for Matter since it uses tilt-capable cluster)
1069
+ if (['blindtilt', 'blind tilt'].includes(raw)) {
1070
+ return 'blindtilt'
1071
+ }
1072
+
1073
+ // Plug variants
1074
+ if (['plug mini (jp)', 'plug mini (us)', 'plug mini (eu)'].includes(raw)) {
1075
+ return 'plug'
1076
+ }
1077
+
1078
+ // Meter variants
1079
+ if (['meterplus', 'meter plus', 'meter plus (jp)', 'meterpro', 'meter pro', 'meterpro(co2)', 'meter pro (co2)'].includes(raw)) {
1080
+ return 'meter'
1081
+ }
1082
+
1083
+ // Relay switch variants
1084
+ if (['relay switch 1', 'relay switch 1pm'].includes(raw)) {
1085
+ return 'relay'
1086
+ }
1087
+
1088
+ // Water detector variants
1089
+ if (['water detector', 'waterdetector'].includes(raw)) {
1090
+ return 'waterdetector'
1091
+ }
1092
+
1093
+ // Fan variants
1094
+ if (['smart fan', 'circulator fan', 'battery circulator fan', 'standing circulator fan'].includes(raw)) {
1095
+ return 'fan'
1096
+ }
1097
+
1098
+ // Light variants
1099
+ if (['strip light', 'strip light 3', 'rgbic neon rope light', 'rgbic neon wire rope light', 'rgbicww floor lamp', 'rgbicww strip light'].includes(raw)) {
1100
+ return 'lightstrip'
1101
+ }
1102
+ if (['color bulb', 'ceiling light', 'ceiling light pro', 'candle warmer lamp', 'floor lamp'].includes(raw)) {
1103
+ return 'light'
1104
+ }
1105
+
1106
+ // Sensor variants
1107
+ if (raw === 'motion sensor') {
1108
+ return 'motion'
1109
+ }
1110
+ if (['contact sensor', 'presence sensor'].includes(raw)) {
1111
+ return 'contact'
1112
+ }
1113
+
1114
+ // Lock variants
1115
+ if (['smart lock', 'smart lock pro', 'smart lock ultra', 'lock lite', 'keypad', 'keypad touch', 'keypad vision', 'keypad vision pro', 'lock vision pro'].includes(raw)) {
1116
+ return 'lock'
1117
+ }
1118
+
1119
+ // Climate variant
1120
+ if (raw === 'humidifier2') {
1121
+ return 'humidifier'
1122
+ }
1123
+
1124
+ return raw
1125
+ }
1126
+
1127
+ /**
1128
+ * Normalizes a Homebridge PlatformConfig object to a SwitchBotPluginConfig.
1129
+ *
1130
+ * @param raw The raw Homebridge platform config object.
1131
+ * @returns The normalized plugin config object.
1132
+ */
26
1133
  export function normalizeConfig(raw?: PlatformConfig): SwitchBotPluginConfig {
27
- if (!raw) return {}
1134
+ if (!raw) {
1135
+ return {}
1136
+ }
28
1137
  return { ...(raw as any) } as SwitchBotPluginConfig
29
1138
  }
30
1139
 
31
1140
  // Create a Proxy constructor that instantiates the right platform implementation at runtime.
1141
+ /**
1142
+ * Creates a proxy class that instantiates the correct platform implementation (HAP or Matter) at runtime.
1143
+ *
1144
+ * @param HAPPlatform The HAP platform class constructor.
1145
+ * @param MatterPlatform The Matter platform class constructor.
1146
+ * @returns A proxy class that delegates to the correct platform implementation.
1147
+ *
1148
+ * @class SwitchBotPlatformProxy
1149
+ * @property impl The instantiated platform implementation (HAP or Matter).
1150
+ */
32
1151
  export function createPlatformProxy(HAPPlatform: any, MatterPlatform: any): any {
33
1152
  return class SwitchBotPlatformProxy {
1153
+ /** The instantiated platform implementation (HAP or Matter) */
34
1154
  private impl: any
1155
+ /**
1156
+ * Constructs the proxy and instantiates the correct platform implementation.
1157
+ * @param log Logger instance
1158
+ * @param config Platform config
1159
+ * @param api Homebridge API instance
1160
+ * @returns The instantiated platform implementation
1161
+ */
35
1162
  constructor(log: any, config: PlatformConfig, api: any) {
36
1163
  const cfg = normalizeConfig(config)
37
1164
  const preferMatter = cfg.preferMatter ?? true
38
1165
  const enableMatter = cfg.enableMatter ?? true
1166
+ const matterAvailable = !!(api?.isMatterAvailable?.() && api?.isMatterEnabled?.())
39
1167
 
40
- if (enableMatter && preferMatter && MatterPlatform) {
1168
+ if (enableMatter && preferMatter && MatterPlatform && matterAvailable) {
41
1169
  this.impl = new MatterPlatform(log, cfg, api)
42
1170
  return this.impl
43
1171
  }