@switchbot/homebridge-switchbot 5.0.0-beta.98 → 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 -450
  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 -526
  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 -850
  267. package/dist/platform.js.map +0 -1
  268. package/src/platform.ts +0 -867
  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
@@ -1,14 +1,172 @@
1
+ // Utility: Validate BLE response length before parsing
2
+ /* eslint-disable style/max-statements-per-line, unused-imports/no-unused-vars */
3
+ import { Buffer } from 'node:buffer';
1
4
  import { MATTER_ATTRIBUTE_IDS, MATTER_CLUSTER_IDS } from '../utils.js';
2
5
  import { DeviceBase } from './deviceBase.js';
6
+ function validateBLEResponseLength(buf, expected, context = '', log) {
7
+ if (!buf || typeof buf.length !== 'number' || buf.length !== expected) {
8
+ log.warn(`[BLE] Invalid response length${context ? ` for ${context}` : ''}: expected ${expected}, got ${buf?.length}`);
9
+ return false;
10
+ }
11
+ return true;
12
+ }
13
+ // BLE notification handling: per-command notification futures and unsolicited notification logging
14
+ const BLE_NOTIFICATION_HANDLERS = new Map();
15
+ // Module-scope regex pattern to avoid recompilation
16
+ const HEX_COLOR_REGEX = /^#?[0-9A-F]{6}$/i;
3
17
  export class GenericDevice extends DeviceBase {
18
+ log;
19
+ _blePollTimer = null;
20
+ _blePollIntervalMs;
21
+ _blePollingEnabled;
4
22
  constructor(opts, cfg) {
5
23
  super(opts, cfg);
24
+ // Require logger from opts or cfg
25
+ this.log = opts?.log || cfg?.log;
26
+ if (!this.log) {
27
+ throw new Error('Device requires a logger (Homebridge logger) in opts or cfg');
28
+ }
29
+ // If BLE encryptionKey/keyId provided, set on node-switchbot device instance if possible
30
+ if (opts.encryptionKey && this.client && typeof this.client.devices?.get === 'function') {
31
+ try {
32
+ const dev = this.client.devices.get(opts.id);
33
+ if (dev && typeof dev.setKey === 'function') {
34
+ dev.setKey({
35
+ encryptionKey: opts.encryptionKey,
36
+ keyId: opts.keyId || undefined,
37
+ });
38
+ }
39
+ }
40
+ catch (e) {
41
+ // ignore if device not found or setKey not available
42
+ }
43
+ }
44
+ // BLE polling config: allow override via opts.blePollingEnabled/blePollIntervalMs or cfg.blePollingEnabled/blePollIntervalMs
45
+ this._blePollingEnabled = opts?.blePollingEnabled ?? cfg?.blePollingEnabled ?? true;
46
+ let pollMs = opts?.blePollIntervalMs ?? cfg?.blePollIntervalMs ?? 10 * 60 * 1000; // default: 10 min
47
+ if (typeof pollMs !== 'number' || Number.isNaN(pollMs) || pollMs < 60000) {
48
+ this.log.warn(`[BLE] Invalid blePollIntervalMs (${pollMs}), using minimum 60000ms`);
49
+ pollMs = 60000;
50
+ }
51
+ this._blePollIntervalMs = pollMs;
52
+ // Subscribe to BLE notifications if supported (node-switchbot v4+)
53
+ this._subscribeBLENotifications();
54
+ // Start BLE polling fallback if enabled
55
+ if (this._blePollingEnabled) {
56
+ this._startBlePolling();
57
+ }
58
+ }
59
+ /**
60
+ * Start periodic BLE polling as a fallback to notifications.
61
+ */
62
+ _startBlePolling() {
63
+ if (this._blePollTimer) {
64
+ clearInterval(this._blePollTimer);
65
+ }
66
+ this._blePollTimer = setInterval(async () => {
67
+ try {
68
+ this.log.debug(`[BLE] Polling getState() for device ${this.opts.id}`);
69
+ await this.getState();
70
+ }
71
+ catch (e) {
72
+ this.log.debug(`[BLE] Polling getState() failed for device ${this.opts.id}:`, e?.message);
73
+ }
74
+ }, this._blePollIntervalMs);
75
+ }
76
+ /**
77
+ * Clean up BLE polling timer on destroy.
78
+ */
79
+ async destroy() {
80
+ if (this._blePollTimer) {
81
+ clearInterval(this._blePollTimer);
82
+ this._blePollTimer = null;
83
+ }
84
+ // Only call super.destroy if DeviceBase.prototype.destroy is a function and not this method itself
85
+ const baseProto = Object.getPrototypeOf(GenericDevice.prototype);
86
+ if (typeof baseProto.destroy === 'function' && baseProto.destroy !== GenericDevice.prototype.destroy) {
87
+ await super.destroy();
88
+ }
89
+ }
90
+ /**
91
+ * Subscribe to BLE notifications for this device (if supported by node-switchbot)
92
+ * Logs unsolicited notifications and enables per-command notification futures.
93
+ */
94
+ async _subscribeBLENotifications() {
95
+ if (!this.client || typeof this.client.devices?.get !== 'function') {
96
+ return;
97
+ }
98
+ const dev = this.client.devices.get(this.opts.id);
99
+ if (!dev || typeof dev.mac !== 'string' || !dev.mac) {
100
+ return;
101
+ }
102
+ // Only subscribe once per device
103
+ if (BLE_NOTIFICATION_HANDLERS.has(dev.mac)) {
104
+ return;
105
+ }
106
+ if (typeof dev.subscribeNotifications === 'function') {
107
+ const handler = (payload) => {
108
+ // If a per-command notification future is waiting, let node-switchbot handle it
109
+ // Otherwise, log unsolicited notification
110
+ if (payload && payload.length > 0) {
111
+ // Unsolicited notification logging
112
+ // (node-switchbot will resolve per-command futures internally)
113
+ this.log.debug(`[BLE] Unsolicited notification from ${dev.mac}: ${payload.toString('hex')}`);
114
+ }
115
+ };
116
+ try {
117
+ // Subscribe and remember handler for possible cleanup
118
+ await dev.subscribeNotifications(handler);
119
+ BLE_NOTIFICATION_HANDLERS.set(dev.mac, handler);
120
+ }
121
+ catch (e) {
122
+ // ignore if subscription fails
123
+ }
124
+ }
125
+ }
126
+ /**
127
+ * Await a BLE notification for this device (for advanced use in subclasses)
128
+ * Returns the notification payload or throws on timeout.
129
+ */
130
+ async _awaitBLENotification(timeoutMs = 5000) {
131
+ if (!this.client || typeof this.client.devices?.get !== 'function') {
132
+ throw new Error('No BLE client/device');
133
+ }
134
+ const dev = this.client.devices.get(this.opts.id);
135
+ if (!dev || typeof dev.mac !== 'string' || !dev.mac) {
136
+ throw new Error('No BLE MAC for device');
137
+ }
138
+ if (typeof dev.bleConnection?.sendCommand !== 'function') {
139
+ throw new TypeError('BLE connection does not support sendCommand');
140
+ }
141
+ // This is a low-level utility; in most cases, node-switchbot handles notification futures for commands
142
+ // Here, we expose a direct await for advanced use
143
+ return new Promise((resolve, reject) => {
144
+ let timer;
145
+ const handler = (payload) => {
146
+ clearTimeout(timer);
147
+ dev.bleConnection?.unsubscribeNotifications(dev.mac, handler);
148
+ resolve(payload);
149
+ };
150
+ dev.bleConnection?.subscribeNotifications(dev.mac, handler).then(() => {
151
+ timer = setTimeout(() => {
152
+ dev.bleConnection?.unsubscribeNotifications(dev.mac, handler);
153
+ reject(new Error('BLE notification timeout'));
154
+ }, timeoutMs);
155
+ }).catch(reject);
156
+ });
6
157
  }
7
158
  async getState() {
8
159
  // Default: return minimal info; implementations should override
9
160
  if (this.client && typeof this.client.getDevice === 'function') {
10
161
  try {
11
162
  const raw = await this.client.getDevice(this.opts.id);
163
+ // If this is a BLE buffer/array, validate length (common BLE status: 12 bytes, but may vary by device)
164
+ if (raw && (raw instanceof Buffer || Array.isArray(raw) || raw instanceof Uint8Array)) {
165
+ // Default to 12, override per device if needed
166
+ if (!validateBLEResponseLength(raw, 12, this.opts.type, this.log)) {
167
+ return { id: this.opts.id, type: this.opts.type, error: 'invalid_ble_response_length', raw };
168
+ }
169
+ }
12
170
  // Normalize common response shapes
13
171
  try {
14
172
  const device = raw?.body ?? raw;
@@ -128,67 +286,150 @@ export class GenericDevice extends DeviceBase {
128
286
  // platform can construct a Matter accessory representation when
129
287
  // Homebridge Matter APIs are available. Device subclasses may override
130
288
  // this to provide Matter-specific clusters/attributes if desired.
131
- createMatterAccessory(api) {
132
- const hapDesc = this.createHAPAccessory(api);
289
+ async createMatterAccessory(api) {
290
+ // Dynamically detect features from getState()
291
+ const state = await this.getState();
133
292
  const clusters = [];
134
- const mapCharacteristic = (charName) => {
135
- switch (charName) {
136
- case 'On':
137
- return { attr: 'onOff', get: async () => { const s = await this.getState(); return !!(s && (s.on === true || s.state === 'on' || s.power === 'on')); }, set: async (v) => this.setState({ on: !!v }) };
138
- case 'Brightness':
139
- return { attr: 'brightness', get: async () => { const s = await this.getState(); return typeof s.brightness === 'number' ? s.brightness : 100; }, set: async (v) => this.setState({ brightness: Number(v) }) };
140
- case 'Hue':
141
- return { attr: 'colorHue', get: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0; }, set: async (v) => this.setState({ hue: Number(v) }) };
142
- case 'Saturation':
143
- return { attr: 'colorSaturation', get: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0; }, set: async (v) => this.setState({ saturation: Number(v) }) };
144
- case 'ColorTemperature':
145
- return { attr: 'colorTemperature', get: async () => {
146
- const s = await this.getState();
147
- if (typeof s.colorTemperature === 'number') {
148
- return s.colorTemperature;
149
- }
150
- if (typeof s.kelvin === 'number') {
151
- return Math.round(1000000 / s.kelvin);
152
- }
153
- return 400;
154
- }, set: async (v) => this.setState({ colorTemperature: Number(v) }) };
155
- case 'RotationSpeed':
156
- return { attr: 'rotationSpeed', get: async () => { const s = await this.getState(); return typeof s.speed === 'number' ? s.speed : 0; }, set: async (v) => this.setState({ speed: Number(v) }) };
157
- case 'TargetPosition':
158
- case 'CurrentPosition':
159
- return { attr: 'position', get: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, set: async (v) => this.setState({ position: Number(v) }) };
160
- case 'LockCurrentState':
161
- case 'LockTargetState':
162
- return { attr: 'locked', get: async () => { const s = await this.getState(); return !!(s && s.locked); }, set: async (v) => this.setState({ locked: !!v }) };
163
- case 'MotionDetected':
164
- return { attr: 'motionDetected', get: async () => { const s = await this.getState(); return !!(s && s.motion === true); }, set: undefined };
165
- case 'ContactSensorState':
166
- return { attr: 'contact', get: async () => { const s = await this.getState(); return s && s.open ? 1 : 0; }, set: undefined };
167
- default:
168
- return null;
169
- }
170
- };
171
- for (const s of (hapDesc.services || [])) {
172
- const clusterType = (() => {
173
- switch ((s.type || '').toLowerCase()) {
174
- case 'lightbulb': return 'OnOff/LevelControl/ColorControl';
175
- case 'fan': return 'FanControl';
176
- case 'windowscovering': return 'Shade';
177
- case 'motionsensor': return 'OccupancySensing';
178
- case 'contactsensor': return 'DoorState';
179
- case 'lockmechanism': return 'DoorLock';
180
- case 'switch': return 'OnOff';
181
- default: return s.type || 'Generic';
182
- }
183
- })();
184
- const attributes = {};
185
- for (const [charName] of Object.entries(s.characteristics || {})) {
186
- const mapped = mapCharacteristic(charName);
187
- if (mapped) {
188
- attributes[mapped.attr] = { read: typeof mapped.get === 'function' ? mapped.get : undefined, write: typeof mapped.set === 'function' ? mapped.set : undefined };
189
- }
190
- }
191
- clusters.push({ type: clusterType, attributes });
293
+ // On/Off (Switch/Plug/Generic)
294
+ if ('on' in state || 'power' in state || 'state' in state) {
295
+ clusters.push({
296
+ type: 'OnOff',
297
+ clusterId: MATTER_CLUSTER_IDS.OnOff,
298
+ attributes: {
299
+ onOff: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v) => this.setState({ on: !!v }) },
300
+ [MATTER_ATTRIBUTE_IDS.OnOff.OnOff]: { read: async () => !!(await this.getState()).on || (await this.getState()).power === 'on' || (await this.getState()).state === 'on', write: async (v) => this.setState({ on: !!v }) },
301
+ },
302
+ });
303
+ }
304
+ // Brightness (Light)
305
+ if ('brightness' in state) {
306
+ clusters.push({
307
+ type: 'LevelControl',
308
+ clusterId: MATTER_CLUSTER_IDS.LevelControl,
309
+ attributes: {
310
+ currentLevel: { read: async () => (await this.getState()).brightness ?? 100, write: async (v) => this.setState({ brightness: Number(v) }) },
311
+ [MATTER_ATTRIBUTE_IDS.LevelControl.CurrentLevel]: { read: async () => (await this.getState()).brightness ?? 100, write: async (v) => this.setState({ brightness: Number(v) }) },
312
+ },
313
+ });
314
+ }
315
+ // Color (Light)
316
+ if ('hue' in state && 'saturation' in state) {
317
+ clusters.push({
318
+ type: 'ColorControl',
319
+ clusterId: MATTER_CLUSTER_IDS.ColorControl,
320
+ attributes: {
321
+ colorMode: { read: async () => 0 },
322
+ colorHue: { read: async () => (await this.getState()).hue ?? 0, write: async (v) => this.setState({ hue: Number(v) }) },
323
+ [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => (await this.getState()).hue ?? 0, write: async (v) => this.setState({ hue: Number(v) }) },
324
+ colorSaturation: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => this.setState({ saturation: Number(v) }) },
325
+ [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentSaturation]: { read: async () => (await this.getState()).saturation ?? 0, write: async (v) => this.setState({ saturation: Number(v) }) },
326
+ ...(typeof state.colorTemperature === 'number' || typeof state.kelvin === 'number'
327
+ ? {
328
+ colorTemperature: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v) => this.setState({ colorTemperature: Number(v) }) },
329
+ [MATTER_ATTRIBUTE_IDS.ColorControl.ColorTemperatureMireds]: { read: async () => typeof (await this.getState()).colorTemperature === 'number' ? (await this.getState()).colorTemperature : Math.round(1000000 / ((await this.getState()).kelvin ?? 2500)), write: async (v) => this.setState({ colorTemperature: Number(v) }) },
330
+ }
331
+ : {}),
332
+ },
333
+ });
334
+ }
335
+ // Temperature sensor
336
+ if ('temperature' in state) {
337
+ clusters.push({
338
+ type: 'TemperatureMeasurement',
339
+ // No clusterId, not present in MATTER_CLUSTER_IDS
340
+ attributes: {
341
+ measuredValue: { read: async () => (await this.getState()).temperature ?? 0 },
342
+ },
343
+ });
344
+ }
345
+ // Humidity sensor
346
+ if ('humidity' in state) {
347
+ clusters.push({
348
+ type: 'RelativeHumidityMeasurement',
349
+ clusterId: MATTER_CLUSTER_IDS.RelativeHumidityMeasurement,
350
+ attributes: {
351
+ measuredValue: { read: async () => (await this.getState()).humidity ?? 0 },
352
+ },
353
+ });
354
+ }
355
+ // CO2 sensor
356
+ if ('CO2' in state) {
357
+ clusters.push({
358
+ type: 'AirQuality',
359
+ attributes: {
360
+ CO2: { read: async () => (await this.getState()).CO2 ?? 0 },
361
+ },
362
+ });
363
+ }
364
+ // Lock
365
+ if ('lockState' in state || 'locked' in state) {
366
+ clusters.push({
367
+ type: 'DoorLock',
368
+ clusterId: MATTER_CLUSTER_IDS.DoorLock,
369
+ attributes: {
370
+ lockState: { read: async () => (await this.getState()).lockState ?? (await this.getState()).locked ? 1 : 0, write: async (v) => this.setState({ locked: !!v }) },
371
+ },
372
+ });
373
+ }
374
+ // Motion sensor
375
+ if ('moveDetected' in state || 'motion' in state) {
376
+ clusters.push({
377
+ type: 'OccupancySensing',
378
+ // No clusterId, not present in MATTER_CLUSTER_IDS
379
+ attributes: {
380
+ occupancy: { read: async () => (await this.getState()).moveDetected === true || (await this.getState()).motion === true ? 1 : 0 },
381
+ },
382
+ });
383
+ }
384
+ // Contact sensor
385
+ if ('openState' in state || 'contact' in state || 'open' in state) {
386
+ clusters.push({
387
+ type: 'BooleanState',
388
+ // No clusterId, not present in MATTER_CLUSTER_IDS
389
+ attributes: {
390
+ stateValue: { read: async () => (await this.getState()).openState === 'open' || (await this.getState()).open === true ? 1 : 0 },
391
+ },
392
+ });
393
+ }
394
+ // Leak sensor
395
+ if ('leak' in state || 'status' in state) {
396
+ clusters.push({
397
+ type: 'LeakSensor',
398
+ attributes: {
399
+ leakDetected: { read: async () => (await this.getState()).leak === true || (await this.getState()).status === 1 ? 1 : 0 },
400
+ },
401
+ });
402
+ }
403
+ // Energy monitoring (Plug)
404
+ if ('voltage' in state || 'power' in state || 'electricCurrent' in state) {
405
+ clusters.push({
406
+ type: 'ElectricalMeasurement',
407
+ // No clusterId, not present in MATTER_CLUSTER_IDS
408
+ attributes: {
409
+ voltage: { read: async () => (await this.getState()).voltage ?? 0 },
410
+ power: { read: async () => (await this.getState()).power ?? 0 },
411
+ electricCurrent: { read: async () => (await this.getState()).electricCurrent ?? 0 },
412
+ },
413
+ });
414
+ }
415
+ // Fan
416
+ if ('speed' in state || 'fanSpeed' in state) {
417
+ clusters.push({
418
+ type: 'FanControl',
419
+ clusterId: MATTER_CLUSTER_IDS.FanControl,
420
+ attributes: {
421
+ speedCurrent: { read: async () => (await this.getState()).speed ?? (await this.getState()).fanSpeed ?? 0, write: async (v) => this.setState({ speed: Number(v) }) },
422
+ },
423
+ });
424
+ }
425
+ // Vacuum
426
+ if ('workingStatus' in state) {
427
+ clusters.push({
428
+ type: 'RobotVacuumCleaner',
429
+ attributes: {
430
+ workingStatus: { read: async () => (await this.getState()).workingStatus ?? 'StandBy' },
431
+ },
432
+ });
192
433
  }
193
434
  return {
194
435
  id: this.opts.id,
@@ -228,25 +469,80 @@ export class CurtainDevice extends GenericDevice {
228
469
  ],
229
470
  };
230
471
  }
231
- // Matter-specific descriptor for Curtain (Shade cluster)
232
- createMatterAccessory(api) {
472
+ // Matter-specific descriptor for Curtain (WindowCovering cluster) with new attributes
473
+ async createMatterAccessory(api) {
474
+ // Get current state for dynamic attributes
475
+ const state = await this.getState();
476
+ // Compose attributes for Matter WindowCovering cluster
477
+ const attributes = {
478
+ currentPositionLiftPercent100ths: {
479
+ read: async () => {
480
+ const s = await this.getState();
481
+ return typeof s.position === 'number' ? Math.round(s.position * 100) : 0;
482
+ },
483
+ write: undefined,
484
+ },
485
+ targetPositionLiftPercent100ths: {
486
+ read: async () => {
487
+ const s = await this.getState();
488
+ return typeof s.position === 'number' ? Math.round(s.position * 100) : 0;
489
+ },
490
+ write: async (v) => this.setState({ position: Math.round(Number(v) / 100) }),
491
+ },
492
+ operationalStatus: {
493
+ read: async () => state.operationalStatus ?? { global: 0, lift: 0, tilt: 0 },
494
+ write: undefined,
495
+ },
496
+ endProductType: {
497
+ read: async () => state.endProductType ?? 0,
498
+ write: undefined,
499
+ },
500
+ configStatus: {
501
+ read: async () => state.configStatus ?? {
502
+ operational: true,
503
+ onlineReserved: true,
504
+ liftMovementReversed: false,
505
+ liftPositionAware: true,
506
+ tiltPositionAware: false,
507
+ liftEncoderControlled: true,
508
+ tiltEncoderControlled: false,
509
+ },
510
+ write: undefined,
511
+ },
512
+ };
513
+ // If tilt is supported, add tilt attributes
514
+ if (typeof state.tilt === 'number') {
515
+ attributes.currentPositionTiltPercent100ths = {
516
+ read: async () => {
517
+ const s = await this.getState();
518
+ return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0;
519
+ },
520
+ write: undefined,
521
+ };
522
+ attributes.targetPositionTiltPercent100ths = {
523
+ read: async () => {
524
+ const s = await this.getState();
525
+ return typeof s.tilt === 'number' ? Math.round(s.tilt * 100) : 0;
526
+ },
527
+ write: async (v) => this.setState({ tilt: Math.round(Number(v) / 100) }),
528
+ };
529
+ }
530
+ const windowCoveringCluster = {
531
+ type: 'WindowCovering',
532
+ clusterId: MATTER_CLUSTER_IDS.WindowCovering,
533
+ attributes,
534
+ };
535
+ // Provide both array and named property for clusters for compatibility with test expectations
536
+ const clustersArr = [windowCoveringCluster];
537
+ const clusters = [...clustersArr];
538
+ // Always set clusters.windowCovering to the WindowCovering cluster by clusterId
539
+ const foundWC = clustersArr.find((c) => c && c.clusterId === MATTER_CLUSTER_IDS.WindowCovering);
540
+ clusters.windowCovering = foundWC || null;
233
541
  return {
234
542
  id: this.opts.id,
235
543
  name: this.opts.name ?? this.opts.type,
236
544
  protocol: 'matter',
237
- clusters: [
238
- {
239
- // Shade cluster
240
- type: 'Shade',
241
- clusterId: MATTER_CLUSTER_IDS.WindowCovering,
242
- attributes: {
243
- currentPosition: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: undefined },
244
- [MATTER_ATTRIBUTE_IDS.WindowCovering.CurrentPosition]: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: undefined },
245
- targetPosition: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: async (v) => this.setState({ position: Number(v) }) },
246
- [MATTER_ATTRIBUTE_IDS.WindowCovering.TargetPosition]: { read: async () => { const s = await this.getState(); return typeof s.position === 'number' ? s.position : 0; }, write: async (v) => this.setState({ position: Number(v) }) },
247
- },
248
- },
249
- ],
545
+ clusters,
250
546
  };
251
547
  }
252
548
  }
@@ -398,7 +694,7 @@ export class LightDevice extends GenericDevice {
398
694
  }
399
695
  // try HSV from color hex
400
696
  const hex = s?.color || s?.colorHex || s?.colour;
401
- if (typeof hex === 'string' && /^#?[0-9A-F]{6}$/i.test(hex)) {
697
+ if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) {
402
698
  const h = (() => {
403
699
  const hsl = (h, s, l) => ({ h, s, l });
404
700
  // convert hex -> rgb -> hsv
@@ -447,7 +743,7 @@ export class LightDevice extends GenericDevice {
447
743
  }
448
744
  // if color hex is available, derive saturation from rgb
449
745
  const hex = s?.color || s?.colorHex || s?.colour;
450
- if (typeof hex === 'string' && /^#?[0-9A-F]{6}$/i.test(hex)) {
746
+ if (typeof hex === 'string' && HEX_COLOR_REGEX.test(hex)) {
451
747
  const cleaned = hex.replace('#', '');
452
748
  const r = Number.parseInt(cleaned.substr(0, 2), 16) / 255;
453
749
  const g = Number.parseInt(cleaned.substr(2, 2), 16) / 255;
@@ -577,6 +873,8 @@ export class LightDevice extends GenericDevice {
577
873
  type: 'ColorControl',
578
874
  clusterId: MATTER_CLUSTER_IDS.ColorControl,
579
875
  attributes: {
876
+ // Required colorMode attribute for Matter conformance (0 = currentHueAndSaturation)
877
+ colorMode: { read: async () => 0 },
580
878
  colorHue: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0; }, write: async (v) => this.setState({ hue: Number(v) }) },
581
879
  [MATTER_ATTRIBUTE_IDS.ColorControl.CurrentHue]: { read: async () => { const s = await this.getState(); return typeof s.hue === 'number' ? s.hue : 0; }, write: async (v) => this.setState({ hue: Number(v) }) },
582
880
  colorSaturation: { read: async () => { const s = await this.getState(); return typeof s.saturation === 'number' ? s.saturation : 0; }, write: async (v) => this.setState({ saturation: Number(v) }) },
@@ -911,7 +1209,7 @@ export class HumidifierDevice extends GenericDevice {
911
1209
  }
912
1210
  // Provide Matter descriptor for humidifier (humidity and on/off)
913
1211
  export class HumidifierMatterDevice extends HumidifierDevice {
914
- createMatterAccessory(api) {
1212
+ async createMatterAccessory(api) {
915
1213
  return {
916
1214
  id: this.opts.id,
917
1215
  name: this.opts.name ?? this.opts.type,