@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
@@ -0,0 +1,110 @@
1
+ import type { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
2
+
3
+ import { RequestError } from '@homebridge/plugin-ui-utils'
4
+ import fs from 'node:fs/promises'
5
+
6
+ import { isValidDeviceType } from '../../device-types.js'
7
+ import { getAllDevices, SWITCHBOT_PLATFORM_REGEX } from '../utils/config-parser.js'
8
+ import { validateAndMigrateDeviceType } from '../utils/device-migration.js'
9
+ import { uiLog } from '../utils/logger.js'
10
+
11
+ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
12
+ /**
13
+ * GET /devices - List all configured devices from Homebridge config
14
+ */
15
+
16
+ server.onRequest('/devices', async () => {
17
+ try {
18
+ const cfgPath = server.homebridgeConfigPath
19
+ if (!cfgPath) {
20
+ throw new Error('HOMEBRIDGE_CONFIG_PATH not set')
21
+ }
22
+
23
+ const raw = await fs.readFile(cfgPath, 'utf8')
24
+ const cfg = JSON.parse(raw)
25
+ const found: Array<{
26
+ id: string
27
+ name?: string
28
+ type?: string
29
+ connectionPreference?: string
30
+ room?: string
31
+ typeValidationWarning?: string
32
+ }> = []
33
+ const invalidTypeDevices: Array<{ name: string, type: string, suggestion?: string }> = []
34
+
35
+ const platforms = Array.isArray(cfg.platforms) ? cfg.platforms : []
36
+ for (const p of platforms) {
37
+ try {
38
+ const platformName = p.platform || p.name || ''
39
+ // Match known SwitchBot platform identifiers
40
+ if (!platformName || !SWITCHBOT_PLATFORM_REGEX.test(String(platformName))) {
41
+ continue
42
+ }
43
+
44
+ const devices = getAllDevices(p)
45
+ for (const d of devices) {
46
+ const id = d.deviceId ?? d.id
47
+ if (!id) {
48
+ continue
49
+ }
50
+
51
+ const deviceType = d.configDeviceType ?? d.type ?? d.deviceType
52
+ const deviceName = d.configDeviceName ?? d.name ?? d.deviceName
53
+
54
+ // Validate device type
55
+ const deviceObj = { configDeviceType: deviceType, configDeviceName: deviceName, deviceId: id }
56
+ const validationResult = validateAndMigrateDeviceType(deviceObj, false)
57
+
58
+ const deviceEntry: typeof found[0] = {
59
+ id,
60
+ name: deviceName,
61
+ type: deviceType,
62
+ connectionPreference: d.connectionPreference ?? d.connection ?? 'auto',
63
+ room: d.room || d.location || undefined,
64
+ }
65
+
66
+ if (!isValidDeviceType(deviceType)) {
67
+ deviceEntry.typeValidationWarning = `Invalid type "${deviceType}"`
68
+ if (validationResult.correctedType) {
69
+ deviceEntry.typeValidationWarning += ` - should be "${validationResult.correctedType}"`
70
+ invalidTypeDevices.push({
71
+ name: deviceName,
72
+ type: deviceType,
73
+ suggestion: validationResult.correctedType,
74
+ })
75
+ } else {
76
+ invalidTypeDevices.push({
77
+ name: deviceName,
78
+ type: deviceType,
79
+ })
80
+ }
81
+ }
82
+
83
+ found.push(deviceEntry)
84
+ }
85
+ } catch (e) {
86
+ // ignore malformed platform entries
87
+ }
88
+ }
89
+
90
+ // Log validation issues
91
+ if (invalidTypeDevices.length > 0) {
92
+ uiLog.warn(
93
+ `Found ${invalidTypeDevices.length} device(s) with invalid types:\n${invalidTypeDevices.map(d => ` - "${d.name}": "${d.type}"${d.suggestion ? ` → should be "${d.suggestion}"` : ''}`).join('\n')}`,
94
+ )
95
+ }
96
+
97
+ uiLog.info(`GET /devices - Found ${found.length} devices in ${cfgPath}${invalidTypeDevices.length > 0 ? ` (${invalidTypeDevices.length} with validation warnings)` : ''}`)
98
+ return {
99
+ success: true,
100
+ data: found,
101
+ ...(invalidTypeDevices.length > 0 && { validationWarnings: invalidTypeDevices }),
102
+ }
103
+ } catch (e) {
104
+ const msg = e instanceof Error ? e.message : String(e)
105
+ uiLog.error(`Error in /devices: ${msg}`)
106
+ // Pass the real error message to the frontend for better diagnostics
107
+ throw new RequestError(msg || 'Failed to read Homebridge config', e)
108
+ }
109
+ })
110
+ }
@@ -0,0 +1,153 @@
1
+ import type { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
2
+
3
+ import { SwitchBotClient } from '../../switchbotClient.js'
4
+ import { getAllDevices, getDevicesRef, getSwitchBotPlatformConfig } from '../utils/config-parser.js'
5
+ import { uiLog } from '../utils/logger.js'
6
+
7
+ /**
8
+ * Register device CRUD endpoints
9
+ */
10
+ export function registerDeviceEndpoints(server: HomebridgePluginUiServer) {
11
+ /**
12
+ * POST /add-devices - Add or update multiple devices in the Homebridge config
13
+ * Expects: { devices: Array<{ deviceId, configDeviceType, configDeviceName, ... }> }
14
+ */
15
+ server.onRequest('/add-devices', async (body: any) => {
16
+ try {
17
+ if (!body || !Array.isArray(body.devices) || body.devices.length === 0) {
18
+ throw new Error('No devices provided')
19
+ }
20
+
21
+ const { platform, cfg, cfgPath } = await getSwitchBotPlatformConfig(server)
22
+ const devicesRef = getDevicesRef(platform)
23
+ const incomingDevices = body.devices
24
+ let added = 0
25
+ let updated = 0
26
+
27
+ for (const newDev of incomingDevices) {
28
+ const id = String(newDev.deviceId || newDev.id || '').trim().toLowerCase()
29
+ if (!id) {
30
+ continue
31
+ }
32
+ const idx = devicesRef.findIndex((d: any) => String(d.deviceId || d.id || '').trim().toLowerCase() === id)
33
+ if (idx >= 0) {
34
+ // Update existing device
35
+ devicesRef[idx] = { ...devicesRef[idx], ...newDev }
36
+ updated++
37
+ } else {
38
+ // Add new device
39
+ devicesRef.push({ ...newDev })
40
+ added++
41
+ }
42
+ }
43
+
44
+ // Log devicesRef and cfgPath before saving
45
+ uiLog.info(`[DEBUG] devicesRef before save:`, JSON.stringify(devicesRef, null, 2))
46
+ uiLog.info(`[DEBUG] cfgPath:`, cfgPath)
47
+ await import('../utils/config-parser.js').then(m => m.saveConfig(cfgPath, cfg))
48
+
49
+ // Log the config file contents after saving for debugging
50
+ try {
51
+ const fs = await import('node:fs/promises')
52
+ const raw = await fs.readFile(cfgPath, 'utf-8')
53
+ uiLog.info(`[DEBUG] Config after add-devices save:`, raw)
54
+ } catch (e) {
55
+ uiLog.warn(`[DEBUG] Could not read config after save:`, e)
56
+ }
57
+
58
+ uiLog.info(`POST /add-devices - Added: ${added}, Updated: ${updated}`)
59
+ return {
60
+ success: true,
61
+ data: {
62
+ added,
63
+ updated,
64
+ total: devicesRef.length,
65
+ },
66
+ }
67
+ } catch (e) {
68
+ const msg = e instanceof Error ? e.message : String(e)
69
+ uiLog.error(`POST /add-devices failed: ${msg}`)
70
+ return {
71
+ success: false,
72
+ error: msg,
73
+ }
74
+ }
75
+ })
76
+ /**
77
+ * POST /test-connection - Test connectivity and basic read for a device
78
+ */
79
+ server.onRequest('/test-connection', async (body: any) => {
80
+ let client: SwitchBotClient | null = null
81
+
82
+ try {
83
+ const deviceId = String(body?.deviceId || '').trim()
84
+ if (!deviceId) {
85
+ throw new Error('Device ID is required')
86
+ }
87
+
88
+ const { platform } = await getSwitchBotPlatformConfig(server)
89
+ const allDevices = getAllDevices(platform)
90
+ const normalizedDeviceId = deviceId.toLowerCase()
91
+ const configuredDevice = allDevices.find((d: any) => String(d.deviceId ?? d.id ?? '').trim().toLowerCase() === normalizedDeviceId)
92
+
93
+ const startedAt = Date.now()
94
+
95
+ client = new SwitchBotClient({
96
+ ...(platform as any),
97
+ logger: uiLog,
98
+ } as any)
99
+
100
+ await client.init()
101
+ const raw = await client.getDevice(deviceId)
102
+ const latencyMs = Date.now() - startedAt
103
+
104
+ const state = raw?.body ?? raw
105
+ const stateConnection = String(state?.connectionType || state?.source || body?.connectionType || '').toLowerCase()
106
+ const method = stateConnection.includes('ble')
107
+ ? 'BLE'
108
+ : stateConnection.includes('api')
109
+ ? 'OpenAPI'
110
+ : 'Auto'
111
+
112
+ return {
113
+ success: true,
114
+ data: {
115
+ success: true,
116
+ deviceId,
117
+ method,
118
+ latencyMs,
119
+ message: configuredDevice
120
+ ? `Connected to "${configuredDevice.configDeviceName || configuredDevice.deviceId || deviceId}"`
121
+ : 'Connected successfully',
122
+ state: {
123
+ online: state?.online,
124
+ power: state?.power,
125
+ battery: state?.battery,
126
+ version: state?.version,
127
+ deviceType: state?.deviceType,
128
+ },
129
+ },
130
+ }
131
+ } catch (e) {
132
+ const message = e instanceof Error ? e.message : String(e)
133
+ uiLog.warn(`POST /test-connection failed: ${message}`)
134
+
135
+ return {
136
+ success: true,
137
+ data: {
138
+ success: false,
139
+ deviceId: String(body?.deviceId || ''),
140
+ method: 'Auto',
141
+ latencyMs: 0,
142
+ message,
143
+ },
144
+ }
145
+ } finally {
146
+ try {
147
+ await client?.destroy()
148
+ } catch (_e) {
149
+ // Ignore client shutdown errors
150
+ }
151
+ }
152
+ })
153
+ }
@@ -0,0 +1,240 @@
1
+ import type { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils'
2
+
3
+ import { RequestError } from '@homebridge/plugin-ui-utils'
4
+
5
+ import { getCredential, getSwitchBotPlatformConfig } from '../utils/config-parser.js'
6
+ import { uiLog } from '../utils/logger.js'
7
+
8
+ /**
9
+ * Register discovery endpoint
10
+ * Discovers SwitchBot devices via BLE and OpenAPI
11
+ */
12
+ export function registerDiscoveryEndpoint(server: HomebridgePluginUiServer) {
13
+ server.onRequest('/ble-status', async () => {
14
+ try {
15
+ const { platform } = await getSwitchBotPlatformConfig(server)
16
+ const token = getCredential(platform, 'openApiToken') || platform.token
17
+ const secret = getCredential(platform, 'openApiSecret') || platform.secret
18
+ const { SwitchBot } = await import('node-switchbot')
19
+ const switchbot = new SwitchBot({
20
+ token: token || undefined,
21
+ secret: secret || undefined,
22
+ enableBLE: true,
23
+ enableFallback: true,
24
+ enableRetry: true,
25
+ enableCircuitBreaker: true,
26
+ enableConnectionIntelligence: true,
27
+ })
28
+
29
+ await switchbot.discover({ timeout: 1000 })
30
+ return { success: true, data: { available: true, message: 'adapter ready' } }
31
+ } catch (e) {
32
+ const message = e instanceof Error ? e.message : String(e)
33
+ uiLog.warn(`GET /ble-status failed: ${message}`)
34
+ return { success: true, data: { available: false, message } }
35
+ }
36
+ })
37
+
38
+ server.onRequest('/discover', async (payload?: any) => {
39
+ uiLog.debug(`[SwitchBot UI/Server] /discover incoming payload: ${JSON.stringify(payload)}`)
40
+ try {
41
+ const { platform } = await getSwitchBotPlatformConfig(server)
42
+ const mode = String(payload?.mode || 'all').toLowerCase()
43
+ // Only run BLE if mode is all/ble AND bleEnabled is not false (default true if missing)
44
+ const bleEnabled = payload?.bleEnabled !== false;
45
+ const runBle = (mode === 'all' || mode === 'ble') && bleEnabled;
46
+ const runOpenApi = mode === 'all' || mode === 'openapi'
47
+ const bleScanDurationSeconds = Math.max(3, Math.min(15, Number(payload?.bleScanDurationSeconds || 5)))
48
+ const bleTimeoutSeconds = Math.max(3, Math.min(30, Number(payload?.bleTimeoutSeconds || 8)))
49
+
50
+ uiLog.debug(`GET /discover - Platform config keys: ${Object.keys(platform).join(', ')}`)
51
+
52
+ const token = getCredential(platform, 'openApiToken') || platform.token
53
+ const secret = getCredential(platform, 'openApiSecret') || platform.secret
54
+
55
+ const hasOpenAPICredentials = !!(token && secret)
56
+
57
+ if (!hasOpenAPICredentials) {
58
+ uiLog.warn('GET /discover - No OpenAPI credentials found, will attempt BLE-only discovery')
59
+ } else {
60
+ uiLog.info('GET /discover - Using OpenAPI credentials for discovery')
61
+ }
62
+
63
+ // Import and initialize node-switchbot
64
+ const { SwitchBot } = await import('node-switchbot')
65
+ const switchbot = new SwitchBot({
66
+ token: token || undefined,
67
+ secret: secret || undefined,
68
+ enableBLE: true,
69
+ enableFallback: true,
70
+ enableRetry: true,
71
+ enableCircuitBreaker: true,
72
+ enableConnectionIntelligence: true,
73
+ })
74
+
75
+ const deviceMap = new Map<string, any>()
76
+
77
+ // 1. Try BLE discovery first (with timeout)
78
+ if (runBle) {
79
+ uiLog.info('GET /discover - Starting BLE scan...')
80
+ try {
81
+ const bleTimeout = bleTimeoutSeconds * 1000
82
+ const bleDiscoveryPromise = switchbot.discover({ timeout: bleScanDurationSeconds * 1000 })
83
+ const bleDevices = await Promise.race([
84
+ bleDiscoveryPromise,
85
+ new Promise<any[]>(resolve => setTimeout(resolve, bleTimeout, [])),
86
+ ])
87
+
88
+ if (Array.isArray(bleDevices) && bleDevices.length > 0) {
89
+ uiLog.info(`GET /discover - Found ${bleDevices.length} BLE devices`)
90
+ for (const [index, d] of bleDevices.entries()) {
91
+ const info = typeof d?.getInfo === 'function' ? d.getInfo() : undefined
92
+ const id = d?.id
93
+ || (typeof d?.getId === 'function' ? d.getId() : undefined)
94
+ || info?.id
95
+
96
+ const mac = d?.mac
97
+ || (typeof d?.getMAC === 'function' ? d.getMAC() : undefined)
98
+ || info?.mac
99
+
100
+ if (!id) {
101
+ uiLog.warn(`GET /discover - BLE device at index ${index} has no id, skipping`)
102
+ continue
103
+ }
104
+
105
+ const connectionTypes = Array.isArray(info?.connectionTypes) ? info.connectionTypes : []
106
+ const isHybrid = connectionTypes.includes('api')
107
+
108
+ deviceMap.set(id, {
109
+ id,
110
+ name: d?.name || (typeof d?.getName === 'function' ? d.getName() : undefined) || info?.name || d?.deviceName || id,
111
+ type: d?.deviceType || (typeof d?.getDeviceType === 'function' ? d.getDeviceType() : undefined) || info?.deviceType || d?.type || d?.model || 'unknown',
112
+ model: info?.model || d?.model || d?.deviceModel,
113
+ address: mac,
114
+ connectionType: isHybrid ? 'Both' : 'BLE',
115
+ rssi: info?.rssi || d?.rssi,
116
+ })
117
+ }
118
+ } else {
119
+ uiLog.info('GET /discover - No BLE devices found or scan timed out')
120
+ }
121
+ } catch (bleErr) {
122
+ uiLog.warn(`GET /discover - BLE discovery failed: ${bleErr instanceof Error ? bleErr.message : String(bleErr)}`)
123
+ // Continue with OpenAPI even if BLE fails
124
+ }
125
+ }
126
+
127
+ // 2. Get devices from OpenAPI (only if credentials are available)
128
+ if (runOpenApi && hasOpenAPICredentials) {
129
+ uiLog.info('GET /discover - Fetching devices from OpenAPI...')
130
+ try {
131
+ const apiClient = switchbot.getAPIClient()
132
+ if (!apiClient) {
133
+ throw new Error('API client not available - token/secret may be missing')
134
+ }
135
+ const apiData = await apiClient.getDevices()
136
+ uiLog.debug(`GET /discover - OpenAPI response: ${JSON.stringify(apiData)}`)
137
+
138
+ // Parse physical devices - apiData is DeviceListResponse with deviceList and infraredRemoteList
139
+ const devices = apiData.deviceList || []
140
+ const irDevices = apiData.infraredRemoteList || []
141
+
142
+ uiLog.info(`GET /discover - Found ${devices.length} OpenAPI physical devices and ${irDevices.length} IR devices`)
143
+
144
+ // Process physical devices from OpenAPI
145
+ for (const d of devices) {
146
+ const id = d.deviceId
147
+ if (!id) {
148
+ continue
149
+ }
150
+
151
+ const existing = deviceMap.get(id)
152
+ if (existing) {
153
+ // Device found via both BLE and OpenAPI
154
+ existing.connectionType = 'Both'
155
+ existing.name = d.deviceName || existing.name
156
+ existing.type = d.deviceType || existing.type
157
+ // Note: APIDevice doesn't have a model property
158
+ existing.enabled = d.enableCloudService !== false
159
+ existing.hubDeviceId = d.hubDeviceId
160
+ } else {
161
+ // Device only found via OpenAPI
162
+ deviceMap.set(id, {
163
+ id,
164
+ name: d.deviceName || id,
165
+ type: d.deviceType || 'unknown',
166
+ enabled: d.enableCloudService !== false,
167
+ hubDeviceId: d.hubDeviceId,
168
+ connectionType: 'OpenAPI',
169
+ })
170
+ }
171
+ }
172
+
173
+ // Process IR devices (OpenAPI only)
174
+ for (const d of irDevices) {
175
+ const id = d.deviceId
176
+ if (!id) {
177
+ continue
178
+ }
179
+
180
+ deviceMap.set(id, {
181
+ id,
182
+ name: d.deviceName || id,
183
+ type: d.remoteType || 'unknown',
184
+ enabled: true,
185
+ hubDeviceId: d.hubDeviceId,
186
+ connectionType: 'OpenAPI',
187
+ isIR: true,
188
+ })
189
+ }
190
+ } catch (apiErr) {
191
+ uiLog.error(`GET /discover - OpenAPI discovery failed: ${apiErr instanceof Error ? apiErr.message : String(apiErr)}`)
192
+ // If we have BLE devices, we can still return those
193
+ if (deviceMap.size === 0) {
194
+ throw apiErr
195
+ }
196
+ }
197
+ } else if (runOpenApi) {
198
+ uiLog.info('GET /discover - Skipping OpenAPI discovery (no credentials configured)')
199
+ }
200
+
201
+ const normalizedBleDevices = [...deviceMap.values()].filter(d => d.connectionType === 'BLE' || d.connectionType === 'Both')
202
+ const firstNormalizedBleId = normalizedBleDevices[0]?.id || 'none'
203
+ uiLog.info(`GET /discover - Normalized BLE devices: ${normalizedBleDevices.length} (firstId: ${firstNormalizedBleId})`)
204
+
205
+ // Check if we found any devices
206
+ if (deviceMap.size === 0 && mode === 'openapi' && !hasOpenAPICredentials) {
207
+ return { success: true, data: [] }
208
+ }
209
+
210
+ if (deviceMap.size === 0) {
211
+ const errorMsg = hasOpenAPICredentials
212
+ ? 'No devices found via BLE or OpenAPI. Make sure devices are powered on and in range.'
213
+ : 'No devices found via BLE. OpenAPI credentials not configured. Please save your credentials in settings to discover cloud-connected devices, or ensure BLE devices are powered on and in range.'
214
+ uiLog.error(`GET /discover - ${errorMsg}`)
215
+ throw new Error(errorMsg)
216
+ }
217
+
218
+ // Convert map to array
219
+ const allDiscovered = [...deviceMap.values()]
220
+
221
+ const bleCount = allDiscovered.filter(d => d.connectionType === 'BLE').length
222
+ const apiCount = allDiscovered.filter(d => d.connectionType === 'OpenAPI').length
223
+ const bothCount = allDiscovered.filter(d => d.connectionType === 'Both').length
224
+
225
+ uiLog.info(`GET /discover - Total: ${allDiscovered.length} devices (BLE: ${bleCount}, OpenAPI: ${apiCount}, Both: ${bothCount})`)
226
+ return { success: true, data: allDiscovered }
227
+ } catch (e) {
228
+ if (e instanceof Error) {
229
+ uiLog.error(`Error in /discover: ${e.message}`)
230
+ if (e.stack) {
231
+ uiLog.error(`[Stack] ${e.stack}`)
232
+ }
233
+ uiLog.error(`[Object]`, e)
234
+ } else {
235
+ uiLog.error(`Error in /discover: ${String(e)}`)
236
+ }
237
+ throw new RequestError(`Failed to discover devices: ${e instanceof Error ? e.message : String(e)}`, e)
238
+ }
239
+ })
240
+ }