@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
@@ -1,50 +1,68 @@
1
1
  import type { SwitchBotPluginConfig } from './settings.js'
2
+ import type { SwitchBot } from 'node-switchbot'
3
+
4
+ import { getDeviceCommandHandler } from './deviceCommandMapper.js'
5
+ import { CharacteristicMissingError, SwitchbotAuthenticationError, SwitchbotOperationError } from './errors.js'
2
6
 
3
7
  export interface ISwitchBotClient {
4
- init(): Promise<void>
5
- getDevice(id: string): Promise<any>
6
- getDevices(): Promise<any[]>
7
- setDeviceState(id: string, body: any): Promise<any>
8
- destroy(): Promise<void>
8
+ init: () => Promise<void>
9
+ getDevice: (id: string) => Promise<any>
10
+ getDevices: () => Promise<any[]>
11
+ setDeviceState: (id: string, body: any) => Promise<any>
12
+ destroy: () => Promise<void>
9
13
  }
10
14
 
11
15
  /**
12
- * Thin wrapper around node-switchbot v4.0.0-beta.2+
16
+ * Thin wrapper around node-switchbot v4.0.0+
13
17
  * Leverages upstream resilience features (retry, circuit breaker, connection intelligence)
14
18
  * while maintaining plugin-specific features like write debouncing and OpenAPI fallback.
15
19
  */
16
20
  export class SwitchBotClient implements ISwitchBotClient {
17
21
  private cfg: SwitchBotPluginConfig
18
- private client: any | null = null
19
- private baseUrl = 'https://api.switch-bot.com/v1.0'
20
- private requestTimeout = 5000
21
- private maxRetries = 2
22
+ private client: SwitchBot | null = null
22
23
  private writeDebounceMs = 100
23
- private logger: any
24
- private pendingWrites: Map<string, { timer: any; body: any; resolvers: Array<{ resolve: (v:any)=>void; reject: (e:any)=>void }>; }> = new Map()
24
+ private discoveryCacheTtlMs = 30_000
25
+ private lastDiscoveryAt = 0
26
+ private logger: import('homebridge').Logger
27
+ private pendingWrites: Map<string, { timer: any, body: any, resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> }> = new Map()
25
28
 
26
29
  constructor(cfg: SwitchBotPluginConfig) {
27
30
  this.cfg = cfg
28
- this.logger = (cfg as any)?.logger ?? console
29
- if (typeof (cfg as any)?.requestTimeout === 'number') this.requestTimeout = (cfg as any).requestTimeout
30
- if (typeof (cfg as any)?.maxRetries === 'number') this.maxRetries = (cfg as any).maxRetries
31
- if (typeof (cfg as any)?.writeDebounceMs === 'number') this.writeDebounceMs = (cfg as any).writeDebounceMs
31
+ this.logger = (cfg as any)?.logger as import('homebridge').Logger
32
+ if (!this.logger) {
33
+ throw new Error('SwitchBotClient requires a logger (Homebridge logger) in config')
34
+ }
35
+ if (typeof (cfg as any)?.writeDebounceMs === 'number') {
36
+ this.writeDebounceMs = (cfg as any).writeDebounceMs
37
+ }
38
+ if (typeof (cfg as any)?.discoveryCacheTtlMs === 'number') {
39
+ this.discoveryCacheTtlMs = Math.max(0, (cfg as any).discoveryCacheTtlMs)
40
+ }
32
41
  }
33
42
 
34
43
  async init(): Promise<void> {
44
+ if (this.client) {
45
+ return
46
+ }
47
+
35
48
  try {
36
49
  // Dynamic import of node-switchbot v4 with native resilience features
37
50
  const { SwitchBot } = await import('node-switchbot')
51
+ const rawNodeClientConfig = typeof (this.cfg as any)?.nodeClientConfig === 'object' ? (this.cfg as any).nodeClientConfig : {}
52
+ const scanTimeout = this.resolveScanTimeoutMs(rawNodeClientConfig)
38
53
  this.client = new SwitchBot({
39
54
  token: this.cfg.openApiToken,
40
55
  secret: this.cfg.openApiSecret,
41
- // Enable all built-in resilience features from node-switchbot v4
42
- enableFallback: true, // Auto-fallback from BLE to API
43
- enableRetry: true, // Retry with exponential backoff
44
- enableCircuitBreaker: true, // Circuit breaker per connection type
45
- enableMetrics: true, // Connection tracking and statistics
46
- ...(typeof (this.cfg as any)?.nodeClientConfig === 'object' && (this.cfg as any).nodeClientConfig),
56
+ // Enable built-in resilience features from node-switchbot v4.
57
+ enableFallback: true, // Auto-fallback from BLE to API
58
+ enableRetry: true, // Retry with exponential backoff
59
+ enableCircuitBreaker: true, // Circuit breaker per connection type
60
+ enableConnectionIntelligence: true, // Connection tracking and route preference
61
+ enableBLE: this.cfg.enableBLE !== false, // Use config value, default true
62
+ scanTimeout,
63
+ ...rawNodeClientConfig,
47
64
  })
65
+ this.lastDiscoveryAt = 0
48
66
  this.logger?.info?.('SwitchBot client initialized with native resilience features')
49
67
  } catch (e) {
50
68
  this.logger?.warn?.('Failed to load node-switchbot; will use OpenAPI fallback:', e)
@@ -52,77 +70,55 @@ export class SwitchBotClient implements ISwitchBotClient {
52
70
  }
53
71
  }
54
72
 
55
- private async fetchWithTimeoutAndRetry(url: string, opts: any = {}, timeoutMs?: number, retries?: number) {
56
- const to = typeof timeoutMs === 'number' ? timeoutMs : this.requestTimeout
57
- const max = typeof retries === 'number' ? retries : this.maxRetries
58
-
59
- let attempt = 0
60
- while (true) {
61
- attempt += 1
62
- const controller = new AbortController()
63
- const timer = setTimeout(() => controller.abort(), to)
73
+ async getDevice(id: string): Promise<any> {
74
+ if (this.client) {
64
75
  try {
65
- const fetchOpts = Object.assign({}, opts)
66
- try {
67
- Object.defineProperty(fetchOpts, 'signal', { value: controller.signal, enumerable: false, configurable: true })
68
- } catch (_e) {
69
- // ignore
76
+ const fromManager = this.getManagedDevice(id)
77
+ if (fromManager) {
78
+ return fromManager
70
79
  }
71
- const resp = await fetch(url, fetchOpts)
72
- clearTimeout(timer)
73
- if (resp.ok) return resp
74
- // server error / rate limit: retry
75
- if (resp.status >= 500 || resp.status === 429) {
76
- throw new Error(`retriable response: ${resp.status}`)
80
+
81
+ const devices = await this.ensureDiscovered(false)
82
+ const fromDiscovery = devices.find((d: any) => d.id === id)
83
+ if (fromDiscovery) {
84
+ return fromDiscovery
77
85
  }
78
- return resp
79
- } catch (err: any) {
80
- clearTimeout(timer)
81
- if (attempt > max) throw err
82
- // exponential backoff
83
- const backoff = Math.min(100 * Math.pow(2, attempt - 1), 2000)
84
- await new Promise((r) => setTimeout(r, backoff))
85
- }
86
- }
87
- }
88
86
 
89
- async getDevice(id: string): Promise<any> {
90
- // Try client API first (with node-switchbot's smart fallback and retry)
91
- if (this.client?.getDevice) {
92
- try {
93
- return await this.client.getDevice(id)
94
- } catch (e) {
95
- this.logger?.warn?.(`Client getDevice failed for ${id}, trying OpenAPI fallback:`, e)
87
+ const refreshDevices = await this.ensureDiscovered(true)
88
+ return refreshDevices.find((d: any) => d.id === id)
89
+ } catch (e: any) {
90
+ if (e instanceof SwitchbotAuthenticationError) {
91
+ this.logger?.error?.(`Authentication error for getDevice(${id}):`, e.message)
92
+ throw e
93
+ } else if (e instanceof SwitchbotOperationError) {
94
+ this.logger?.warn?.(`Operation error for getDevice(${id}):`, e.message, e.code)
95
+ throw e
96
+ } else if (e instanceof CharacteristicMissingError) {
97
+ this.logger?.warn?.(`Characteristic missing for getDevice(${id}):`, e.characteristic)
98
+ throw e
99
+ } else {
100
+ this.logger?.warn?.(`Client getDevice failed for ${id}:`, e)
101
+ throw e
102
+ }
96
103
  }
97
104
  }
98
- // Fallback: call OpenAPI via HTTP
99
- if (this.cfg.openApiToken) {
100
- const url = `${this.baseUrl}/devices/${id}`
101
- const opts = { headers: { Authorization: this.cfg.openApiToken } }
102
- const resp = await this.fetchWithTimeoutAndRetry(url, opts)
103
- return resp.json()
104
- }
105
- throw new Error('No SwitchBot client available')
105
+ throw new SwitchbotOperationError('No SwitchBot client available', 'no_client')
106
106
  }
107
107
 
108
108
  async getDevices(): Promise<any[]> {
109
- // Try client API first
110
- if (this.client?.getDevices) {
109
+ if (this.client) {
111
110
  try {
112
- return await this.client.getDevices()
111
+ const fromManager = this.getManagedDevices()
112
+ if (fromManager.length > 0) {
113
+ return fromManager
114
+ }
115
+ return await this.ensureDiscovered(false)
113
116
  } catch (e) {
114
- this.logger?.warn?.('Client getDevices failed, trying OpenAPI fallback:', e)
117
+ this.logger?.warn?.('Client getDevices failed:', e)
118
+ throw e
115
119
  }
116
120
  }
117
- // Fallback: call OpenAPI
118
- if (this.cfg.openApiToken) {
119
- const url = `${this.baseUrl}/devices`
120
- const opts = { headers: { Authorization: this.cfg.openApiToken } }
121
- const resp = await this.fetchWithTimeoutAndRetry(url, opts)
122
- const data = await resp.json()
123
- return (data?.body || data) as any[]
124
- }
125
- return []
121
+ throw new SwitchbotOperationError('No SwitchBot client available', 'no_client')
126
122
  }
127
123
 
128
124
  async setDeviceState(id: string, body: any): Promise<any> {
@@ -139,15 +135,24 @@ export class SwitchBotClient implements ISwitchBotClient {
139
135
  return
140
136
  }
141
137
 
142
- const resolvers: Array<{ resolve: (v:any)=>void; reject: (e:any)=>void }> = [{ resolve, reject }]
138
+ const resolvers: Array<{ resolve: (v: any) => void, reject: (e: any) => void }> = [{ resolve, reject }]
143
139
  const timer = setTimeout(async () => {
144
140
  const entry = this.pendingWrites.get(id)
145
- if (!entry) return
141
+ if (!entry) {
142
+ return
143
+ }
146
144
  this.pendingWrites.delete(id)
147
145
  try {
148
146
  const out = await this._doSetDeviceState(id, entry.body)
149
147
  for (const r of entry.resolvers) r.resolve(out)
150
- } catch (e) {
148
+ } catch (e: any) {
149
+ if (e instanceof SwitchbotAuthenticationError) {
150
+ this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
151
+ } else if (e instanceof SwitchbotOperationError) {
152
+ this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
153
+ } else if (e instanceof CharacteristicMissingError) {
154
+ this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
155
+ }
151
156
  for (const r of entry.resolvers) r.reject(e)
152
157
  }
153
158
  }, this.writeDebounceMs)
@@ -157,47 +162,105 @@ export class SwitchBotClient implements ISwitchBotClient {
157
162
  }
158
163
 
159
164
  private async _doSetDeviceState(id: string, body: any): Promise<any> {
160
- // Prefer client API (which has node-switchbot's native resilience)
161
- if (this.client?.setDeviceState) {
162
- try {
163
- return await this.client.setDeviceState(id, body)
164
- } catch (e) {
165
- this.logger?.warn?.(`Client setDeviceState failed for ${id}, trying fallback:`, e)
165
+ if (!this.client) {
166
+ throw new SwitchbotOperationError('No SwitchBot client available for setDeviceState', 'no_client')
167
+ }
168
+ try {
169
+ const device = await this.getDevice(id)
170
+ if (!device) {
171
+ throw new SwitchbotOperationError(`Device ${id} not found`, 'device_not_found')
172
+ }
173
+ const deviceType = (device.deviceType ?? '').toLowerCase()
174
+ const command = body?.command
175
+ if (!command) {
176
+ throw new SwitchbotOperationError('No command specified in body', 'no_command')
177
+ }
178
+ const handler = getDeviceCommandHandler(deviceType, command)
179
+ if (!handler) {
180
+ throw new SwitchbotOperationError(`Unsupported command '${command}' for device type '${deviceType}'`, 'unsupported_command')
181
+ }
182
+ this.logger?.debug?.(`[${id}] Calling mapped command '${command}' for device type '${deviceType}'`)
183
+ return await handler(device, body)
184
+ } catch (e: any) {
185
+ if (e instanceof SwitchbotAuthenticationError) {
186
+ this.logger?.error?.(`Authentication error for setDeviceState(${id}):`, e.message)
187
+ throw e
188
+ } else if (e instanceof SwitchbotOperationError) {
189
+ this.logger?.warn?.(`Operation error for setDeviceState(${id}):`, e.message, e.code)
190
+ throw e
191
+ } else if (e instanceof CharacteristicMissingError) {
192
+ this.logger?.warn?.(`Characteristic missing for setDeviceState(${id}):`, e.characteristic)
193
+ throw e
194
+ } else {
195
+ this.logger?.warn?.(`Device command failed for ${id}:`, e)
196
+ throw e
166
197
  }
167
198
  }
199
+ }
168
200
 
169
- // Try generic sendCommand if available
170
- if (this.client?.sendCommand) {
171
- try {
172
- return await this.client.sendCommand(id, body)
173
- } catch (e) {
174
- this.logger?.warn?.(`Client sendCommand failed for ${id}, trying OpenAPI:`, e)
201
+ async destroy(): Promise<void> {
202
+ for (const [, pending] of this.pendingWrites) {
203
+ clearTimeout(pending.timer)
204
+ const err = new SwitchbotOperationError('Client destroyed before pending write was sent', 'client_destroyed')
205
+ for (const r of pending.resolvers) {
206
+ r.reject(err)
175
207
  }
176
208
  }
209
+ this.pendingWrites.clear()
177
210
 
178
- // OpenAPI fallback
179
- if (this.cfg.openApiToken) {
180
- const url = `${this.baseUrl}/devices/${id}/commands`
181
- const opts = {
182
- method: 'POST',
183
- headers: {
184
- Authorization: this.cfg.openApiToken,
185
- 'Content-Type': 'application/json',
186
- },
187
- body: JSON.stringify(body),
188
- }
189
- const resp = await this.fetchWithTimeoutAndRetry(url, opts)
190
- return resp.json()
211
+ if (this.client?.cleanup) {
212
+ await this.client.cleanup()
213
+ }
214
+ this.client = null
215
+ this.lastDiscoveryAt = 0
216
+ }
217
+
218
+ private resolveScanTimeoutMs(rawNodeClientConfig: Record<string, any>): number {
219
+ if (typeof rawNodeClientConfig.scanTimeout === 'number' && Number.isFinite(rawNodeClientConfig.scanTimeout)) {
220
+ return Math.max(500, rawNodeClientConfig.scanTimeout)
221
+ }
222
+
223
+ if (typeof rawNodeClientConfig.scanDuration === 'number' && Number.isFinite(rawNodeClientConfig.scanDuration)) {
224
+ return Math.max(500, rawNodeClientConfig.scanDuration)
225
+ }
226
+
227
+ if (typeof (this.cfg as any)?.bleScanDurationSeconds === 'number') {
228
+ return Math.max(500, (this.cfg as any).bleScanDurationSeconds * 1000)
191
229
  }
192
230
 
193
- throw new Error('No SwitchBot client available for setDeviceState')
231
+ return 5000
194
232
  }
195
233
 
196
- async destroy(): Promise<void> {
197
- if (this.client?.destroy) {
198
- await this.client.destroy()
234
+ private getManagedDevice(id: string): any {
235
+ const manager = (this.client as any)?.devices
236
+ if (manager?.get) {
237
+ return manager.get(id)
199
238
  }
200
- this.client = null
239
+ return undefined
240
+ }
241
+
242
+ private getManagedDevices(): any[] {
243
+ const manager = (this.client as any)?.devices
244
+ if (manager?.list) {
245
+ const list = manager.list()
246
+ return Array.isArray(list) ? list : []
247
+ }
248
+ return []
201
249
  }
202
- }
203
250
 
251
+ private async ensureDiscovered(force: boolean): Promise<any[]> {
252
+ if (!this.client) {
253
+ throw new SwitchbotOperationError('No SwitchBot client available', 'no_client')
254
+ }
255
+
256
+ const fromManager = this.getManagedDevices()
257
+ const cacheValid = this.discoveryCacheTtlMs > 0 && (Date.now() - this.lastDiscoveryAt) < this.discoveryCacheTtlMs
258
+ if (!force && cacheValid && fromManager.length > 0) {
259
+ return fromManager
260
+ }
261
+
262
+ const discovered = await this.client.discover()
263
+ this.lastDiscoveryAt = Date.now()
264
+ return discovered
265
+ }
266
+ }