@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
@@ -0,0 +1,558 @@
1
+ import type { SwitchBotPluginConfig } from './settings.js'
2
+ import type { API, Logger, PlatformConfig } from 'homebridge'
3
+
4
+ import { createDevice } from './deviceFactory.js'
5
+ import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
6
+ import { SwitchBotClient } from './switchbotClient.js'
7
+ import { normalizeTypeForMatter } from './utils.js'
8
+
9
+ /**
10
+ * Homebridge platform class for SwitchBot HAP (HomeKit Accessory Protocol) integration.
11
+ * Handles device discovery, registration, polling, and accessory lifecycle for HAP-enabled SwitchBot devices.
12
+ *
13
+ * @class SwitchBotHAPPlatform
14
+ * @param {Logger} log - Homebridge logger instance
15
+ * @param {PlatformConfig} config - Platform configuration object
16
+ * @param {API} [api] - Optional Homebridge API instance
17
+ * @property {API | undefined} api - Homebridge API instance
18
+ * @property {Logger} log - Homebridge logger instance
19
+ * @property {SwitchBotPluginConfig} config - Parsed plugin config
20
+ * @property {any[]} devices - All created device instances
21
+ * @property {Map<string, any>} accessories - Map of accessory UUID to accessory object
22
+ * @property {string} lastConfigHash - Hash of last loaded config for change detection
23
+ * @property {NodeJS.Timeout | null} configReloadInterval - Interval for periodic config reload
24
+ * @property {Map<string, NodeJS.Timeout>} openApiPollTimers - Timers for per-device OpenAPI polling
25
+ * @property {NodeJS.Timeout | null} openApiBatchTimer - Timer for batched OpenAPI polling
26
+ * @property {number} openApiRequestsToday - Count of OpenAPI requests made today
27
+ * @property {number} openApiLastReset - Timestamp (ms) of last OpenAPI daily counter reset
28
+ */
29
+ export class SwitchBotHAPPlatform {
30
+ /** Homebridge API instance */
31
+ api: API | undefined
32
+ /** Homebridge logger instance */
33
+ log: Logger
34
+ /** Parsed plugin config */
35
+ config: SwitchBotPluginConfig
36
+ /** All created device instances */
37
+ devices: any[] = []
38
+ /** Map of accessory UUID to accessory object */
39
+ accessories: Map<string, any>
40
+ /** Hash of last loaded config for change detection */
41
+ private lastConfigHash: string = ''
42
+ /** Interval for periodic config reload */
43
+ private configReloadInterval: NodeJS.Timeout | null = null
44
+ /** Timers for per-device OpenAPI polling */
45
+ private openApiPollTimers: Map<string, NodeJS.Timeout> = new Map()
46
+ /** Timer for batched OpenAPI polling */
47
+ private openApiBatchTimer: NodeJS.Timeout | null = null
48
+ /** Count of OpenAPI requests made today */
49
+ private openApiRequestsToday = 0
50
+ /** Timestamp (ms) of last OpenAPI daily counter reset */
51
+ private openApiLastReset = 0
52
+
53
+ /**
54
+ * Construct the SwitchBot HAP platform.
55
+ * @param log Homebridge logger
56
+ * @param config Platform config
57
+ * @param api Homebridge API instance
58
+ */
59
+ constructor(log: Logger, config: PlatformConfig, api?: API) {
60
+ this.log = log
61
+ // Ensure both log and logger are set for downstream device constructors
62
+ this.config = { ...(config as any), log, logger: log }
63
+ this.api = api
64
+ this.accessories = new Map()
65
+ this.log.info('SwitchBot HAP platform initialized')
66
+
67
+ // Create/shared SwitchBot client and attach to config so child devices reuse it.
68
+ try {
69
+ const client = new SwitchBotClient(this.config)
70
+ void client.init();
71
+ (this.config as any)._client = client
72
+ } catch (e) {
73
+ this.log.debug('Failed to create shared SwitchBot client', e)
74
+ }
75
+
76
+ // Wait for Homebridge to finish launching to create/register accessories
77
+ if (this.api && typeof (this.api as any).on === 'function') {
78
+ (this.api as any).on('didFinishLaunching', async () => {
79
+ await this.loadDevices()
80
+ this._setupOpenApiPolling()
81
+ // Start periodic config reload to pick up UI changes
82
+ this.configReloadInterval = setInterval(() => {
83
+ void this.checkAndReloadDevices()
84
+ }, 10000) // Check every 10 seconds
85
+ // Listen for Homebridge shutdown to clear interval
86
+ if (typeof (this.api as any).on === 'function') {
87
+ (this.api as any).on('shutdown', () => {
88
+ this.shutdown()
89
+ })
90
+ }
91
+ })
92
+ } else {
93
+ void this.loadDevices()
94
+ this._setupOpenApiPolling()
95
+ // Start periodic config reload to pick up UI changes
96
+ this.configReloadInterval = setInterval(() => {
97
+ void this.checkAndReloadDevices()
98
+ }, 10000) // Check every 10 seconds
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Discover and create all device instances from config.
104
+ * Populates this.devices and registers HAP accessories for each device.
105
+ * Ensures Matter API is loaded before loading devices if available.
106
+ *
107
+ * @returns {Promise<void>} Resolves when all devices are loaded and registered
108
+ */
109
+ async loadDevices(): Promise<void> {
110
+ // Matter API readiness logic (hybrid only)
111
+ const maxAttempts = 20
112
+ let attempt = 0
113
+ let matterLoaded = false
114
+ if (this.api && typeof (this.api as any).isMatterAvailable === 'function' && typeof (this.api as any).isMatterEnabled === 'function' && typeof (this.api as any).loadMatterAPI === 'function') {
115
+ if ((this.api as any).isMatterAvailable() && (this.api as any).isMatterEnabled()) {
116
+ try {
117
+ await (this.api as any).loadMatterAPI()
118
+ this.log.info('Homebridge Matter API loaded successfully')
119
+ } catch (e) {
120
+ this.log.warn('Failed to load Homebridge Matter API', e)
121
+ }
122
+ while (attempt < maxAttempts) {
123
+ if ((this.api as any).matter) {
124
+ matterLoaded = true
125
+ break
126
+ }
127
+ await new Promise(res => setTimeout(res, 500))
128
+ attempt++
129
+ }
130
+ if (!matterLoaded) {
131
+ this.log.warn('Matter API did not become available after loadMatterAPI()')
132
+ }
133
+ }
134
+ }
135
+
136
+ const newHash = this.getConfigHash()
137
+ if (newHash === this.lastConfigHash) {
138
+ this.log.debug('Config unchanged, skipping device reload')
139
+ return
140
+ }
141
+
142
+ const devices = (this.config as any)?.devices ?? []
143
+ const createdDevices: { created: any, d: any, type: string }[] = []
144
+ for (const raw of devices) {
145
+ // Normalize config keys from UI schema to internal shape (for cross-platform consistency)
146
+ const d: any = {
147
+ id: raw.deviceId ?? raw.id,
148
+ name: raw.configDeviceName ?? raw.name,
149
+ type: raw.configDeviceType ?? raw.type ?? raw.deviceType ?? 'unknown',
150
+ encryptionKey: raw.encryptionKey,
151
+ keyId: raw.keyId,
152
+ _raw: raw,
153
+ }
154
+ const type: string = normalizeTypeForMatter(d.type)
155
+ const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId, log: this.log }
156
+ this.log.debug(`[HAP/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
157
+ try {
158
+ const created = await createDevice(deviceOpts, this.config, false)
159
+ this.devices.push(created)
160
+ createdDevices.push({ created, d, type })
161
+ } catch (e: any) {
162
+ this.log.error(`Failed to create HAP device ${d.id}:`, e instanceof Error ? e.stack || e.message : e)
163
+ }
164
+ }
165
+ // Register HAP accessories after all devices are created for symmetry with Matter platform
166
+ await this.registerHAPAccessories(createdDevices)
167
+ this.lastConfigHash = newHash
168
+ }
169
+
170
+ /**
171
+ * Registers all HAP accessories with the Homebridge HAP API.
172
+ *
173
+ * This method is called after all device instances have been created. It handles both new and restored
174
+ * accessories, updating their context and adding or updating HAP services and characteristics as needed.
175
+ * Accessories are registered with Homebridge using the HAP API, and the internal accessory map is updated accordingly.
176
+ *
177
+ * @param {Array<{created: any, d: any, type: string}>} createdDevices - Array of device descriptors:
178
+ * - created: The created device instance
179
+ * - d: The normalized device config object
180
+ * - type: The normalized device type string
181
+ * @returns {Promise<void>} Resolves when registration is complete
182
+ *
183
+ * Differences from Matter registration:
184
+ * - Uses the HAP API (not Matter API) for accessory registration.
185
+ * - Adds HAP services and characteristics to each accessory based on the device descriptor.
186
+ * - Does not require or check for Matter support or availability.
187
+ * - Accessory context and service wiring are HAP-specific.
188
+ *
189
+ * If the Homebridge HAP API is not available, registration is skipped and a log message is emitted.
190
+ */
191
+ private async registerHAPAccessories(createdDevices: { created: any, d: any, type: string }[]): Promise<void> {
192
+ if (!this.api || !(this.api as any).hap || typeof (this.api as any).hap.registerPlatformAccessories !== 'function') {
193
+ this.log.info('HAP API not available to register accessories')
194
+ return
195
+ }
196
+ const hap = (this.api as any).hap
197
+ const accessoriesToRegister: any[] = []
198
+ for (const { created, d, type } of createdDevices) {
199
+ try {
200
+ const uuid = hap.uuid.generate(`${d.id}`)
201
+ let accessory: any = this.accessories.get(uuid)
202
+ if (!accessory) {
203
+ for (const [, a] of this.accessories.entries()) {
204
+ try {
205
+ if (a && a.context && a.context.deviceId === d.id) {
206
+ accessory = a
207
+ break
208
+ }
209
+ } catch (e) {
210
+ // ignore
211
+ }
212
+ }
213
+ }
214
+ if (!accessory) {
215
+ accessory = new (this.api as any).platformAccessory(d.name || type, uuid)
216
+ try {
217
+ accessory.context = accessory.context || {}
218
+ accessory.context.deviceId = d.id
219
+ accessory.context.type = type
220
+ } catch (e) {
221
+ // ignore context failures
222
+ }
223
+ accessoriesToRegister.push(accessory)
224
+ this.accessories.set(uuid, accessory)
225
+ } else {
226
+ try {
227
+ accessory.context = accessory.context || {}
228
+ accessory.context.deviceId = accessory.context.deviceId || d.id
229
+ accessory.context.type = accessory.context.type || type
230
+ accessory.deviceType = type
231
+ const accDesc = await created.createAccessory?.(this.api)
232
+ accessory.displayName = (accDesc && accDesc.name) || d.name || type
233
+ accessory.manufacturer = (accDesc && accDesc.manufacturer) || accessory.manufacturer || 'SwitchBot'
234
+ accessory.model = (accDesc && accDesc.model) || accessory.model || type
235
+ accessory.serialNumber = (accDesc && accDesc.serialNumber) || accessory.serialNumber || d.id
236
+ accessory.firmwareRevision = (accDesc && accDesc.firmwareRevision) || accessory.firmwareRevision || '1.0.0'
237
+ accessory.hardwareRevision = (accDesc && accDesc.hardwareRevision) || accessory.hardwareRevision || ''
238
+ accessory.UUID = accessory.UUID || accessory.uuid || uuid
239
+ } catch (e) {
240
+ // ignore
241
+ }
242
+ }
243
+ // Add basic service descriptor from device (symmetrical to Matter: remove stale services/chars)
244
+ const accDesc = await created.createAccessory?.(this.api)
245
+ if (accDesc && accDesc.services) {
246
+ const serviceTypes = accDesc.services.map((s: any) => s.type)
247
+ for (const existingService of accessory.services.slice()) {
248
+ if (existingService.constructor && existingService.constructor.name && !serviceTypes.includes(existingService.constructor.name)) {
249
+ accessory.removeService(existingService)
250
+ }
251
+ }
252
+ for (const s of accDesc.services) {
253
+ const Service = hap.Service[s.type] || hap.Service[s.type]
254
+ if (!Service) {
255
+ continue
256
+ }
257
+ const service = accessory.getService(Service) || accessory.addService(Service)
258
+ const charNames = Object.keys(s.characteristics || {})
259
+ for (const existingChar of service.characteristics.slice()) {
260
+ if (!charNames.includes(existingChar.displayName)) {
261
+ service.removeCharacteristic(existingChar)
262
+ }
263
+ }
264
+ for (const [charName, getterSetterRaw] of Object.entries(s.characteristics || {})) {
265
+ const getterSetter: any = getterSetterRaw
266
+ const Characteristic = (hap.Characteristic as any)[charName]
267
+ if (!Characteristic) {
268
+ continue
269
+ }
270
+ if (getterSetter && getterSetter.props) {
271
+ try {
272
+ service.getCharacteristic(Characteristic).setProps(getterSetter.props)
273
+ } catch (e) {
274
+ // ignore setProps failures on older HAP implementations
275
+ }
276
+ }
277
+ if (getterSetter && typeof getterSetter.get === 'function') {
278
+ service.getCharacteristic(Characteristic).onGet(getterSetter.get)
279
+ }
280
+ if (getterSetter && typeof getterSetter.set === 'function') {
281
+ service.getCharacteristic(Characteristic).onSet(getterSetter.set)
282
+ }
283
+ }
284
+ }
285
+ }
286
+ this.log.info(`Created/updated HAP accessory ${d.id} (${type})`)
287
+ } catch (e) {
288
+ this.log.warn('HAP accessory creation failed', e)
289
+ }
290
+ }
291
+ if (accessoriesToRegister.length > 0) {
292
+ try {
293
+ (this.api as any).registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRegister)
294
+ this.log.info(`Registered ${accessoriesToRegister.length} HAP accessory(ies) with Homebridge`)
295
+ } catch (e) {
296
+ this.log.warn('Failed to register HAP accessories', e)
297
+ }
298
+ } else {
299
+ this.log.info('No HAP accessories to register')
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Returns the timestamp (ms) of the last OpenAPI daily counter reset.
305
+ * @returns {number} Timestamp in ms
306
+ */
307
+ getOpenApiLastReset(): number {
308
+ return this.openApiLastReset
309
+ }
310
+
311
+ /**
312
+ * Logs the last OpenAPI reset time in a human-readable format.
313
+ * @returns {void}
314
+ */
315
+ logOpenApiLastReset(): void {
316
+ if (this.openApiLastReset) {
317
+ const date = new Date(this.openApiLastReset)
318
+ this.log.info(`[OpenAPI] Last daily counter reset: ${date.toLocaleString()}`)
319
+ } else {
320
+ this.log.info('[OpenAPI] Daily counter has not been reset yet.')
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Compute a hash of the current device config for change detection.
326
+ * @returns {string} JSON string hash of device config
327
+ */
328
+ private getConfigHash(): string {
329
+ // Create a simple hash of current device config to detect changes
330
+ const devices = (this.config as any)?.devices ?? []
331
+ return JSON.stringify(devices.map((d: any) => ({
332
+ id: d.deviceId ?? d.id,
333
+ type: d.configDeviceType ?? d.type,
334
+ name: d.configDeviceName ?? d.name,
335
+ })))
336
+ }
337
+
338
+ /**
339
+ * Reload devices if config has changed since last load.
340
+ * Unregisters accessories and removes devices no longer in config.
341
+ * Calls loadDevices to repopulate devices and accessories.
342
+ *
343
+ * @returns {Promise<void>} Resolves when reload is complete
344
+ */
345
+ private async checkAndReloadDevices(): Promise<void> {
346
+ const currentHash = this.getConfigHash()
347
+ if (currentHash !== this.lastConfigHash) {
348
+ this.log.info('[SwitchBot] Detected config changes, reloading devices...')
349
+ // Identify device IDs in new config
350
+ const devicesInConfig = new Set(((this.config as any)?.devices ?? []).map((d: any) => d.deviceId ?? d.id))
351
+ // Find accessories to remove (not in config)
352
+ const accessoriesToRemove: any[] = []
353
+ for (const [uuid, accessory] of this.accessories.entries()) {
354
+ const deviceId = accessory?.context?.deviceId
355
+ if (deviceId && !devicesInConfig.has(deviceId)) {
356
+ accessoriesToRemove.push({ accessory, uuid })
357
+ }
358
+ }
359
+ // Unregister removed accessories from Homebridge
360
+ if (accessoriesToRemove.length > 0 && this.api && (this.api as any).unregisterPlatformAccessories) {
361
+ try {
362
+ (this.api as any).unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemove.map(a => a.accessory))
363
+ this.log.info(`Unregistered ${accessoriesToRemove.length} accessory(ies) removed from config`)
364
+ for (const { uuid } of accessoriesToRemove) {
365
+ this.accessories.delete(uuid)
366
+ }
367
+ } catch (e) {
368
+ this.log.warn('Failed to unregister removed accessories', e)
369
+ }
370
+ }
371
+ // Remove devices from this.devices that are no longer in config
372
+ this.devices = this.devices.filter((dev: any) => devicesInConfig.has(dev?.id))
373
+ await this.loadDevices()
374
+ this.lastConfigHash = currentHash
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Cleanup method to clear config reload interval on shutdown.
380
+ * Called by Homebridge on shutdown event.
381
+ * @returns {void}
382
+ */
383
+ shutdown(): void {
384
+ if (this.configReloadInterval) {
385
+ clearInterval(this.configReloadInterval)
386
+ this.configReloadInterval = null
387
+ this.log.info('Cleared config reload interval on shutdown')
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Setup OpenAPI polling for all devices according to config (global, per-device, batch, rate limit).
393
+ * Handles daily request limits, per-device and batch polling, and resets.
394
+ *
395
+ * @returns {void}
396
+ */
397
+ private _setupOpenApiPolling(): void {
398
+ // Clear any existing timers
399
+ for (const t of this.openApiPollTimers.values()) clearInterval(t)
400
+ this.openApiPollTimers.clear()
401
+ if (this.openApiBatchTimer) {
402
+ clearInterval(this.openApiBatchTimer)
403
+ }
404
+ this.openApiBatchTimer = null
405
+
406
+ const cfg = this.config as any
407
+ const devices = cfg.devices ?? []
408
+ const globalRate = Math.max(Number(cfg.openApiRefreshRate) || 300, 30)
409
+ const batchEnabled = cfg.matterBatchEnabled !== false
410
+ const batchRate = Math.max(Number(cfg.matterBatchRefreshRate) || globalRate, 30)
411
+ const batchConcurrency = Math.max(Number(cfg.matterBatchConcurrency) || 5, 1)
412
+ const batchJitter = Math.max(Number(cfg.matterBatchJitter) || 0, 0)
413
+ const dailyLimit = Math.max(Number(cfg.dailyApiLimit) || 10000, 1000)
414
+ const dailyReserve = Math.max(Number(cfg.dailyApiReserveForCommands) || 1000, 0)
415
+ const resetAtLocalMidnight = !!cfg.dailyApiResetLocalMidnight
416
+ const webhookOnlyOnReserve = !!cfg.webhookOnlyOnReserve
417
+
418
+ // Helper to reset daily counter
419
+ const resetCounter = () => {
420
+ this.openApiRequestsToday = 0
421
+ this.openApiLastReset = Date.now()
422
+ this.log.info('[OpenAPI] Daily request counter reset')
423
+ }
424
+ // Schedule reset at midnight
425
+ const scheduleMidnightReset = () => {
426
+ const now = new Date()
427
+ let nextReset: Date
428
+ if (resetAtLocalMidnight) {
429
+ nextReset = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1)
430
+ } else {
431
+ nextReset = new Date(now)
432
+ nextReset.setUTCHours(24, 0, 1, 0)
433
+ }
434
+ const ms = nextReset.getTime() - now.getTime()
435
+ setTimeout(() => {
436
+ resetCounter()
437
+ scheduleMidnightReset()
438
+ }, ms)
439
+ }
440
+ scheduleMidnightReset()
441
+
442
+ // Helper to check if polling is allowed
443
+ const canPoll = () => {
444
+ if (this.openApiRequestsToday + dailyReserve >= dailyLimit) {
445
+ if (!webhookOnlyOnReserve) {
446
+ this.log.warn('[OpenAPI] Daily request limit reached, pausing background polling')
447
+ }
448
+ return false
449
+ }
450
+ return true
451
+ }
452
+
453
+ // Per-device polling (devices with per-device refreshRate)
454
+ for (const dev of devices) {
455
+ const id = dev.deviceId ?? dev.id
456
+ const enabled = dev.enabled !== false
457
+ if (!id || !enabled) {
458
+ continue
459
+ }
460
+ const perDeviceRate = dev.refreshRate ? Math.max(Number(dev.refreshRate), 30) : null
461
+ if (perDeviceRate) {
462
+ // Individual polling interval for this device
463
+ const timer = setInterval(async () => {
464
+ if (!canPoll()) {
465
+ return
466
+ }
467
+ try {
468
+ const client = (this.config as any)._client
469
+ if (client && typeof client.getDevice === 'function') {
470
+ await client.getDevice(id)
471
+ this.openApiRequestsToday++
472
+ this.log.debug(`[OpenAPI] Polled device ${id} (per-device interval ${perDeviceRate}s) [${this.openApiRequestsToday}/${dailyLimit}]`)
473
+ }
474
+ } catch (e) {
475
+ this.log.debug(`[OpenAPI] Polling failed for device ${id}:`, (e as Error)?.message)
476
+ }
477
+ }, perDeviceRate * 1000)
478
+ this.openApiPollTimers.set(id, timer)
479
+ }
480
+ }
481
+
482
+ // Batched polling for all other devices
483
+ if (batchEnabled) {
484
+ // Devices not already polled individually
485
+ const batchDevices = devices.filter((dev: any) => {
486
+ const id = dev.deviceId ?? dev.id
487
+ const enabled = dev.enabled !== false
488
+ const perDeviceRate = dev.refreshRate ? Math.max(Number(dev.refreshRate), 30) : null
489
+ return id && enabled && !perDeviceRate
490
+ })
491
+ // Optional jitter before first batch
492
+ const startBatch = () => {
493
+ this.openApiBatchTimer = setInterval(async () => {
494
+ if (!canPoll()) {
495
+ return
496
+ }
497
+ const client = (this.config as any)._client
498
+ if (!client || typeof client.getDevice !== 'function') {
499
+ return
500
+ }
501
+ // Limit concurrency
502
+ const chunks: any[][] = []
503
+ for (let i = 0; i < batchDevices.length; i += batchConcurrency) {
504
+ chunks.push(batchDevices.slice(i, i + batchConcurrency))
505
+ }
506
+ for (const chunk of chunks) {
507
+ await Promise.all(chunk.map(async (dev: any) => {
508
+ try {
509
+ await client.getDevice(dev.deviceId ?? dev.id)
510
+ this.openApiRequestsToday++
511
+ this.log.debug(`[OpenAPI] Batched poll device ${dev.deviceId ?? dev.id} [${this.openApiRequestsToday}/${dailyLimit}]`)
512
+ } catch (e) {
513
+ this.log.debug(`[OpenAPI] Batched polling failed for device ${dev.deviceId ?? dev.id}:`, (e as Error)?.message)
514
+ }
515
+ }))
516
+ }
517
+ }, batchRate * 1000)
518
+ }
519
+ if (batchJitter > 0) {
520
+ setTimeout(startBatch, Math.floor(Math.random() * batchJitter * 1000))
521
+ } else {
522
+ startBatch()
523
+ }
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Called by Homebridge to restore cached HAP accessories on startup.
529
+ * @param {any} accessory - The cached accessory object
530
+ * @returns {Promise<void>} Resolves when accessory is restored
531
+ */
532
+ async configureAccessory(accessory: any): Promise<void> {
533
+ try {
534
+ const uuid = accessory.UUID || accessory.UUID
535
+ this.accessories.set(uuid, accessory)
536
+ this.log.info(`Restored cached accessory ${accessory.displayName || uuid}`)
537
+ } catch (e) {
538
+ this.log.warn('configureAccessory failed to restore accessory', e)
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Called by Homebridge when a cached Matter accessory is restored (optional signature).
544
+ * @param {any} accessory - The cached accessory object
545
+ * @returns {void}
546
+ */
547
+ configureMatterAccessory?(accessory: any): void {
548
+ try {
549
+ const uuid = accessory.uuid || accessory.UUID || accessory.uuid
550
+ this.accessories.set(uuid, accessory)
551
+ this.log.info(`Restored cached Matter accessory ${accessory.displayName || uuid}`)
552
+ } catch (e) {
553
+ this.log.warn('configureMatterAccessory failed to restore accessory', e)
554
+ }
555
+ }
556
+ }
557
+
558
+ export default SwitchBotHAPPlatform