@switchbot/homebridge-switchbot 5.1.0-beta.9 → 5.1.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 (130) hide show
  1. package/.github/copilot-instructions.md +0 -63
  2. package/.github/scripts/normalize-changelog-header.cjs +1 -1
  3. package/.github/scripts/npm-version-script-esm-auto.js +1 -1
  4. package/.github/scripts/resolve-version-bump-esm.js +1 -1
  5. package/CHANGELOG.md +10 -9
  6. package/MIGRATION.md +0 -5
  7. package/README.md +0 -25
  8. package/config.schema.json +0 -11
  9. package/dist/{Platform.HAP.d.ts → SwitchBotHAPPlatform.d.ts} +16 -2
  10. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
  11. package/dist/{Platform.HAP.js → SwitchBotHAPPlatform.js} +111 -40
  12. package/dist/SwitchBotHAPPlatform.js.map +1 -0
  13. package/dist/{Platform.Matter.d.ts → SwitchBotMatterPlatform.d.ts} +2 -4
  14. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
  15. package/dist/{Platform.Matter.js → SwitchBotMatterPlatform.js} +6 -62
  16. package/dist/SwitchBotMatterPlatform.js.map +1 -0
  17. package/dist/deviceFactory.d.ts.map +1 -1
  18. package/dist/deviceFactory.js +5 -15
  19. package/dist/deviceFactory.js.map +1 -1
  20. package/dist/devices/genericDevice.d.ts +63 -5
  21. package/dist/devices/genericDevice.d.ts.map +1 -1
  22. package/dist/devices/genericDevice.js +251 -11
  23. package/dist/devices/genericDevice.js.map +1 -1
  24. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -1
  25. package/dist/homebridge-ui/endpoints/config.js +2 -9
  26. package/dist/homebridge-ui/endpoints/config.js.map +1 -1
  27. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +2 -9
  28. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -1
  29. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +0 -8
  30. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -1
  31. package/dist/homebridge-ui/public/css/styles.css +1 -61
  32. package/dist/homebridge-ui/public/index.html +2 -61
  33. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
  34. package/dist/homebridge-ui/public/js/advanced-settings.js +3 -4
  35. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
  36. package/dist/homebridge-ui/public/js/advanced-settings.ts +3 -4
  37. package/dist/homebridge-ui/public/js/api.d.ts +0 -16
  38. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  39. package/dist/homebridge-ui/public/js/api.js +0 -48
  40. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  41. package/dist/homebridge-ui/public/js/api.ts +1 -61
  42. package/dist/homebridge-ui/public/js/app.js +115 -267
  43. package/dist/homebridge-ui/public/js/app.js.map +4 -4
  44. package/dist/homebridge-ui/public/js/app.ts +4 -47
  45. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
  46. package/dist/homebridge-ui/public/js/credentials.js +5 -11
  47. package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
  48. package/dist/homebridge-ui/public/js/credentials.ts +5 -11
  49. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  50. package/dist/homebridge-ui/public/js/devices.js +0 -6
  51. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  52. package/dist/homebridge-ui/public/js/devices.ts +0 -6
  53. package/dist/homebridge-ui/public/js/modals.d.ts +0 -1
  54. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  55. package/dist/homebridge-ui/public/js/modals.js +0 -53
  56. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  57. package/dist/homebridge-ui/public/js/modals.ts +0 -58
  58. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  59. package/dist/homebridge-ui/public/js/render.js +1 -2
  60. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  61. package/dist/homebridge-ui/public/js/render.ts +1 -2
  62. package/dist/homebridge-ui/settings.js.map +1 -1
  63. package/dist/homebridge-ui/utils/config-parser.d.ts +0 -8
  64. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -1
  65. package/dist/homebridge-ui/utils/config-parser.js +0 -8
  66. package/dist/homebridge-ui/utils/config-parser.js.map +1 -1
  67. package/dist/index.js +2 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/settings.d.ts +0 -11
  70. package/dist/settings.d.ts.map +1 -1
  71. package/dist/settings.js.map +1 -1
  72. package/dist/utils.d.ts +0 -5
  73. package/dist/utils.d.ts.map +1 -1
  74. package/dist/utils.js +0 -27
  75. package/dist/utils.js.map +1 -1
  76. package/docs/assets/highlight.css +15 -15
  77. package/docs/index.html +5 -22
  78. package/docs/variables/default.html +1 -1
  79. package/package.json +15 -9
  80. package/scripts/build-ui.js +0 -30
  81. package/src/{Platform.HAP.ts → SwitchBotHAPPlatform.ts} +112 -39
  82. package/src/{Platform.Matter.ts → SwitchBotMatterPlatform.ts} +8 -63
  83. package/src/deviceFactory.ts +5 -14
  84. package/src/devices/genericDevice.ts +271 -12
  85. package/src/homebridge-ui/endpoints/config.ts +2 -10
  86. package/src/homebridge-ui/public/css/styles.css +1 -61
  87. package/src/homebridge-ui/public/index.html +0 -59
  88. package/src/homebridge-ui/public/js/advanced-settings.ts +3 -4
  89. package/src/homebridge-ui/public/js/api.ts +1 -61
  90. package/src/homebridge-ui/public/js/app.ts +4 -47
  91. package/src/homebridge-ui/public/js/credentials.ts +5 -11
  92. package/src/homebridge-ui/public/js/devices.ts +0 -6
  93. package/src/homebridge-ui/public/js/modals.ts +0 -58
  94. package/src/homebridge-ui/public/js/render.ts +1 -2
  95. package/src/homebridge-ui/utils/config-parser.ts +0 -9
  96. package/src/index.ts +2 -2
  97. package/src/settings.ts +0 -12
  98. package/src/utils.ts +0 -31
  99. package/test/device/deviceFactory.spec.ts +9 -0
  100. package/test/device/getstate-cache.spec.ts +62 -0
  101. package/test/device/getstate-returns-status.spec.ts +89 -0
  102. package/test/device/water-detector-battery.spec.ts +81 -0
  103. package/test/device/water-detector-leak.spec.ts +43 -0
  104. package/test/hap-push-updates.spec.ts +150 -0
  105. package/test/platform/accessory-restore.spec.ts +2 -2
  106. package/test/platform/matter-childbridge.spec.ts +1 -1
  107. package/test/platform/matter-integration.spec.ts +1 -51
  108. package/test/platform/platform-edge.spec.ts +2 -2
  109. package/test/platform/platform.integration.spec.ts +1 -1
  110. package/test/utils/utils.spec.ts +0 -15
  111. package/.github/npm-version-script-esm.js +0 -97
  112. package/dist/Platform.HAP.d.ts.map +0 -1
  113. package/dist/Platform.HAP.js.map +0 -1
  114. package/dist/Platform.Matter.d.ts.map +0 -1
  115. package/dist/Platform.Matter.js.map +0 -1
  116. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js +0 -23
  117. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js.map +0 -1
  118. package/dist/homebridge-ui/public/js/legacy-view.d.ts +0 -3
  119. package/dist/homebridge-ui/public/js/legacy-view.d.ts.map +0 -1
  120. package/dist/homebridge-ui/public/js/legacy-view.js +0 -45
  121. package/dist/homebridge-ui/public/js/legacy-view.js.map +0 -1
  122. package/dist/homebridge-ui/public/js/legacy-view.ts +0 -107
  123. package/dist/homebridge-ui/utils/v4-detection.d.ts +0 -12
  124. package/dist/homebridge-ui/utils/v4-detection.d.ts.map +0 -1
  125. package/dist/homebridge-ui/utils/v4-detection.js +0 -23
  126. package/dist/homebridge-ui/utils/v4-detection.js.map +0 -1
  127. package/src/homebridge-ui/public/js/legacy-view.js +0 -45
  128. package/src/homebridge-ui/public/js/legacy-view.ts +0 -107
  129. package/src/homebridge-ui/utils/v4-detection.ts +0 -23
  130. package/test/homebridge-ui/config-parser.spec.ts +0 -61
@@ -1,11 +1,10 @@
1
- import type { API, Logger, PlatformConfig } from 'homebridge'
2
-
3
1
  import type { SwitchBotPluginConfig } from './settings.js'
2
+ import type { API, Logger, PlatformConfig } from 'homebridge'
4
3
 
5
4
  import { createDevice } from './deviceFactory.js'
6
5
  import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
7
6
  import { SwitchBotClient } from './switchbotClient.js'
8
- import { createMatterHandlers, DEVICE_MATTER_CLUSTERS, DEVICE_MATTER_SUPPORTED, normalizeTypeForMatter, resolveExternalPublishProtocol, resolveMatterDeviceType } from './utils.js'
7
+ import { createMatterHandlers, DEVICE_MATTER_CLUSTERS, DEVICE_MATTER_SUPPORTED, normalizeTypeForMatter, resolveMatterDeviceType } from './utils.js'
9
8
 
10
9
  /**
11
10
  * Homebridge platform class for SwitchBot Matter integration.
@@ -114,7 +113,7 @@ export class SwitchBotMatterPlatform {
114
113
  }
115
114
 
116
115
  const devices = (this.config as any)?.devices ?? []
117
- const createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean, externalPublishProtocol: 'none' | 'hap' | 'matter' }[] = []
116
+ const createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean }[] = []
118
117
  for (const raw of devices) {
119
118
  // Normalize config keys from UI schema to internal shape (for cross-platform consistency)
120
119
  const d: any = {
@@ -126,7 +125,6 @@ export class SwitchBotMatterPlatform {
126
125
  _raw: raw,
127
126
  }
128
127
  const type: string = normalizeTypeForMatter(d.type)
129
- const externalPublishProtocol = resolveExternalPublishProtocol(raw)
130
128
  const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId, log: this.log }
131
129
  this.log.debug(`[Matter/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
132
130
  const matterSupported = !!DEVICE_MATTER_SUPPORTED[(type || '').toLowerCase()]
@@ -136,7 +134,7 @@ export class SwitchBotMatterPlatform {
136
134
  try {
137
135
  const created = await createDevice(deviceOpts, this.config, useMatter)
138
136
  this.devices.push(created)
139
- createdDevices.push({ created, d, type, useMatter, matterAvailable, externalPublishProtocol })
137
+ createdDevices.push({ created, d, type, useMatter, matterAvailable })
140
138
  if (useMatter) {
141
139
  this.log.info(`Prepared Matter accessory for ${d.id} (${type})${matterAvailable ? ' (auto-detected)' : ' (manually enabled)'}`)
142
140
  } else {
@@ -181,15 +179,14 @@ export class SwitchBotMatterPlatform {
181
179
  * If the Homebridge Matter API is not available, registration is skipped and a log message is emitted.
182
180
  * Accessories that are not enabled for Matter are ignored.
183
181
  */
184
- async registerMatterAccessories(createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean, externalPublishProtocol: 'none' | 'hap' | 'matter' }[]): Promise<void> {
182
+ async registerMatterAccessories(createdDevices: { created: any, d: any, type: string, useMatter: boolean, matterAvailable: boolean }[]): Promise<void> {
185
183
  if (!this.api || !(this.api as any).matter || typeof (this.api as any).matter.registerPlatformAccessories !== 'function') {
186
184
  this.log.info('Homebridge Matter API not available; skipping Matter accessory registration')
187
185
  return
188
186
  }
189
187
  const matterApi = (this.api as any).matter
190
188
  const accessoriesToRegister: any[] = []
191
- const externalAccessoriesToPublish: any[] = []
192
- for (const { created, d, type, useMatter, matterAvailable, externalPublishProtocol } of createdDevices) {
189
+ for (const { created, d, type, useMatter, matterAvailable } of createdDevices) {
193
190
  // Only register accessories where Matter is enabled and supported
194
191
  if (!useMatter) {
195
192
  // Log reason for skipping registration
@@ -240,11 +237,7 @@ export class SwitchBotMatterPlatform {
240
237
  handlers: createdDesc.handlers || createMatterHandlers(this.log, d.id, type, (this.config as any)?._client) || undefined,
241
238
  context: { deviceId: d.id, type, created: true },
242
239
  }
243
- if (externalPublishProtocol === 'matter') {
244
- externalAccessoriesToPublish.push(accessory)
245
- } else {
246
- accessoriesToRegister.push(accessory)
247
- }
240
+ accessoriesToRegister.push(accessory)
248
241
  this.accessories.set(uuid, accessory)
249
242
  } else {
250
243
  // Ensure context and update properties for restored accessory
@@ -267,11 +260,7 @@ export class SwitchBotMatterPlatform {
267
260
  accessory.handlers = createdDesc.handlers || createMatterHandlers(this.log, d.id, type, (this.config as any)?._client) || undefined
268
261
  accessory.displayName = createdDesc.name || d.name || type
269
262
  accessory.UUID = accessory.UUID || accessory.uuid || uuid
270
- if (externalPublishProtocol === 'matter') {
271
- externalAccessoriesToPublish.push(accessory)
272
- } else {
273
- accessoriesToRegister.push(accessory)
274
- }
263
+ accessoriesToRegister.push(accessory)
275
264
  this.accessories.set(accessory.UUID || uuid, accessory)
276
265
  }
277
266
  this.log.info(`Created/updated Matter accessory ${d.id} (${type})`)
@@ -289,49 +278,6 @@ export class SwitchBotMatterPlatform {
289
278
  } else {
290
279
  this.log.info('No Matter accessories to register')
291
280
  }
292
- if (externalAccessoriesToPublish.length > 0) {
293
- try {
294
- if (typeof matterApi.publishExternalAccessories === 'function') {
295
- await matterApi.publishExternalAccessories(PLUGIN_NAME, externalAccessoriesToPublish)
296
- this.log.info(`Published ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
297
- } else if (typeof matterApi.registerExternalAccessories === 'function') {
298
- await matterApi.registerExternalAccessories(PLUGIN_NAME, PLATFORM_NAME, externalAccessoriesToPublish)
299
- this.log.info(`Registered ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
300
- } else if (await this.publishMatterExternalAccessories(externalAccessoriesToPublish)) {
301
- this.log.info(`Published ${externalAccessoriesToPublish.length} Matter external accessory(ies) with Homebridge`)
302
- } else {
303
- this.log.warn('Matter external accessory publishing API not available; falling back to regular platform registration')
304
- await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, externalAccessoriesToPublish)
305
- this.log.info(`Registered ${externalAccessoriesToPublish.length} Matter accessory(ies) as fallback (external publishing unavailable)`)
306
- }
307
- } catch (e) {
308
- this.log.warn('Failed to publish/register Matter external accessories', e)
309
- }
310
- }
311
- }
312
-
313
- private async publishMatterExternalAccessories(accessories: any[]): Promise<boolean> {
314
- const api = this.api as any
315
- if (!api || typeof api.emit !== 'function') {
316
- return false
317
- }
318
-
319
- const pendingRegistrations = api._pendingExternalRegistrations
320
- if (!(pendingRegistrations instanceof Map) && pendingRegistrations !== undefined) {
321
- return false
322
- }
323
-
324
- const registrationId = `${PLUGIN_NAME}-${Date.now()}-${Math.random()}`
325
- const registrationPromise = new Promise<void>((resolve) => {
326
- if (!(api._pendingExternalRegistrations instanceof Map)) {
327
- api._pendingExternalRegistrations = new Map()
328
- }
329
- api._pendingExternalRegistrations.set(registrationId, resolve)
330
- })
331
-
332
- api.emit('publishExternalMatterAccessories', accessories, registrationId)
333
- await registrationPromise
334
- return true
335
281
  }
336
282
 
337
283
  /**
@@ -366,7 +312,6 @@ export class SwitchBotMatterPlatform {
366
312
  id: d.deviceId ?? d.id,
367
313
  type: d.configDeviceType ?? d.type,
368
314
  name: d.configDeviceName ?? d.name,
369
- externalPublishProtocol: resolveExternalPublishProtocol(d),
370
315
  })))
371
316
  }
372
317
 
@@ -109,6 +109,7 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
109
109
  'meter': MeterDevice,
110
110
  'meter plus (jp)': MeterDevice,
111
111
  'water detector': WaterDetectorDevice,
112
+ 'waterdetector': WaterDetectorDevice,
112
113
  'smart fan': SmartFanDevice,
113
114
  'strip light': StripLightDevice,
114
115
  'wosweeper': WoSweeperDevice,
@@ -176,20 +177,10 @@ export async function createDevice(opts: DeviceOptions, cfg: SwitchBotPluginConf
176
177
  const device = new DeviceCtor(deviceOpts, mergedCfg)
177
178
  await device.init()
178
179
 
179
- // Attach a simple getState delegator to the client where appropriate
180
- const originalGetState = device.getState.bind(device)
181
- device.getState = async () => {
182
- try {
183
- // Prefer client-backed getDevice when available
184
- const dev = await client.getDevice(opts.id)
185
- if (dev) {
186
- return dev
187
- }
188
- } catch (e) {
189
- // ignore and fallback to device implementation
190
- }
191
- return originalGetState()
192
- }
180
+ // No getState() override here: the device's own implementation already
181
+ // prefers the client-backed lookup, and it converts the result into a status.
182
+ // An override that returned the client device directly shadowed it, and that
183
+ // object carries no readings.
193
184
 
194
185
  // Provide accessory factory based on platform selection
195
186
  return {
@@ -186,7 +186,44 @@ export class GenericDevice extends DeviceBase {
186
186
  })
187
187
  }
188
188
 
189
+ /**
190
+ * How long a fetched state is reused. HomeKit reads every characteristic of
191
+ * an accessory together, so without this a single read costs one API request
192
+ * per characteristic.
193
+ */
194
+ private static readonly STATE_TTL_MS = 15_000
195
+
196
+ /** Marks a state that carries no readings, so a caller can skip it. */
197
+ static readonly UNREADABLE = Symbol('unreadable')
198
+
199
+ private stateCache?: { at: number, value: any }
200
+ private stateInFlight?: Promise<any>
201
+
189
202
  async getState(): Promise<any> {
203
+ if (this.stateCache && (Date.now() - this.stateCache.at) < GenericDevice.STATE_TTL_MS) {
204
+ return this.stateCache.value
205
+ }
206
+ // Collapse the burst of reads HomeKit makes into a single fetch.
207
+ if (this.stateInFlight) {
208
+ return this.stateInFlight
209
+ }
210
+ this.stateInFlight = this.fetchState()
211
+ .then((value) => {
212
+ if (value === GenericDevice.UNREADABLE) {
213
+ // Do not cache a failure. Caching it would hold every characteristic
214
+ // at its fallback for the whole window after one transient error.
215
+ return { id: this.opts.id, type: this.opts.type, unreadable: true }
216
+ }
217
+ this.stateCache = { at: Date.now(), value }
218
+ return value
219
+ })
220
+ .finally(() => {
221
+ this.stateInFlight = undefined
222
+ })
223
+ return this.stateInFlight
224
+ }
225
+
226
+ protected async fetchState(): Promise<any> {
190
227
  // Default: return minimal info; implementations should override
191
228
  if (this.client && typeof this.client.getDevice === 'function') {
192
229
  try {
@@ -201,15 +238,27 @@ export class GenericDevice extends DeviceBase {
201
238
  // Normalize common response shapes
202
239
  try {
203
240
  const device = raw?.body ?? raw
204
- return device
205
- } catch (e) {
206
- return raw
241
+ // node-switchbot returns a device instance, not a status. The instance
242
+ // exposes id/name/deviceType/mac but carries no readings, so a caller
243
+ // reading state.temperature would get undefined. Ask it for status.
244
+ if (device && typeof device.getStatus === 'function') {
245
+ const status = await device.getStatus()
246
+ if (status && typeof status === 'object') {
247
+ return status
248
+ }
249
+ // Fall through to the minimal info below rather than returning the
250
+ // instance, which would look like a state with every field missing.
251
+ } else {
252
+ return device
253
+ }
254
+ } catch (e: any) {
255
+ this.log?.debug?.(`getState: could not read status for ${this.opts.id}:`, e instanceof Error ? e.message : e)
207
256
  }
208
257
  } catch (e) {
209
258
  // ignore and fallback
210
259
  }
211
260
  }
212
- return { id: this.opts.id, type: this.opts.type }
261
+ return GenericDevice.UNREADABLE
213
262
  }
214
263
 
215
264
  async setState(change: any): Promise<any> {
@@ -483,6 +532,38 @@ export class GenericDevice extends DeviceBase {
483
532
  export class BotDevice extends GenericDevice {}
484
533
 
485
534
  export class CurtainDevice extends GenericDevice {
535
+ private lastKnownPosition = 0
536
+ private lastTargetPosition = 0
537
+ private positionState = 2
538
+ private preferLocalPositionUntil = 0
539
+
540
+ private clampHomeKitPosition(position: number): number {
541
+ return Math.max(0, Math.min(100, Math.round(Number(position))))
542
+ }
543
+
544
+ private toHomeKitPosition(switchBotPosition: number): number {
545
+ return 100 - this.clampHomeKitPosition(switchBotPosition)
546
+ }
547
+
548
+ private toSwitchBotPosition(homeKitPosition: number): number {
549
+ return 100 - this.clampHomeKitPosition(homeKitPosition)
550
+ }
551
+
552
+ private async getPositionForHomeKit(): Promise<number> {
553
+ if (Date.now() < this.preferLocalPositionUntil) {
554
+ return this.lastKnownPosition
555
+ }
556
+ const state = await Promise.race([
557
+ this.getState(),
558
+ new Promise(resolve => setTimeout(resolve, 1000)),
559
+ ])
560
+ if (typeof state?.position === 'number') {
561
+ this.lastKnownPosition = this.toHomeKitPosition(Number(state.position))
562
+ this.lastTargetPosition = this.lastKnownPosition
563
+ }
564
+ return this.lastKnownPosition
565
+ }
566
+
486
567
  createHAPAccessory(api: any) {
487
568
  return {
488
569
  services: [
@@ -491,18 +572,27 @@ export class CurtainDevice extends GenericDevice {
491
572
  characteristics: {
492
573
  CurrentPosition: {
493
574
  get: async () => {
494
- const s = await this.getState()
495
- return typeof s.position === 'number' ? s.position : 0
575
+ return this.getPositionForHomeKit()
496
576
  },
497
577
  },
578
+ PositionState: {
579
+ get: async () => this.positionState,
580
+ },
498
581
  TargetPosition: {
499
582
  get: async () => {
500
- const s = await this.getState()
501
- return typeof s.position === 'number' ? s.position : 0
583
+ await this.getPositionForHomeKit()
584
+ return this.lastTargetPosition
502
585
  },
503
586
  set: async (v: any) => {
504
- await this.setState({ position: Number(v) })
587
+ const position = this.clampHomeKitPosition(Number(v))
588
+ this.lastTargetPosition = position
589
+ this.positionState = position > this.lastKnownPosition ? 1 : position < this.lastKnownPosition ? 0 : 2
590
+ await this.setState({ position: this.toSwitchBotPosition(position) })
591
+ this.lastKnownPosition = position
592
+ this.preferLocalPositionUntil = Date.now() + 30000
593
+ this.positionState = 2
505
594
  },
595
+ refreshAfterSet: ['CurrentPosition', 'TargetPosition', 'PositionState'],
506
596
  },
507
597
  },
508
598
  },
@@ -1306,6 +1396,161 @@ export class MeterDevice extends GenericDevice {
1306
1396
  }
1307
1397
 
1308
1398
  export class WaterDetectorDevice extends GenericDevice {
1399
+ private _leakRefreshing = false
1400
+ private _leakRefreshTs = 0
1401
+ private lastLeakDetected?: boolean
1402
+ private readonly LEAK_REFRESH_TTL_MS = 30_000
1403
+
1404
+ private lastBatteryLevel?: number
1405
+ private _batteryRefreshing = false
1406
+ private _batteryRefreshTs = 0
1407
+ private readonly BATTERY_REFRESH_TTL_MS = 30_000
1408
+
1409
+ async init(): Promise<void> {
1410
+ await super.init()
1411
+ await this._refreshLeakState(!this.client)
1412
+ await this._refreshBattery()
1413
+ }
1414
+
1415
+ private normalizeLeakDetected(state: any): boolean | undefined {
1416
+ if (typeof state?.leak === 'boolean') {
1417
+ return state.leak
1418
+ }
1419
+ if (typeof state?.waterLeakDetected === 'boolean') {
1420
+ return state.waterLeakDetected
1421
+ }
1422
+ if (typeof state?.body?.waterLeakDetected === 'boolean') {
1423
+ return state.body.waterLeakDetected
1424
+ }
1425
+ const status = state?.status ?? state?.body?.status
1426
+ if (typeof status === 'number') {
1427
+ return status === 1
1428
+ }
1429
+ if (typeof status === 'string') {
1430
+ return ['1', 'leak', 'leaked', 'detected', 'water_leak_detected'].includes(status.toLowerCase())
1431
+ }
1432
+ return undefined
1433
+ }
1434
+
1435
+ private normalizeBatteryLevel(state: any): number | undefined {
1436
+ const raw = state?.battery ?? state?.body?.battery
1437
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
1438
+ return undefined
1439
+ }
1440
+ return Math.max(0, Math.min(100, raw))
1441
+ }
1442
+
1443
+ private async getOpenAPIStatus(): Promise<any> {
1444
+ const token = this.cfg?.openApiToken
1445
+ const secret = this.cfg?.openApiSecret
1446
+ if (!token || !secret) {
1447
+ return undefined
1448
+ }
1449
+
1450
+ try {
1451
+ const { OpenAPIClient } = await import('node-switchbot')
1452
+ const apiClient = new OpenAPIClient(token, secret)
1453
+ return await apiClient.getStatus(this.opts.id)
1454
+ } catch (e) {
1455
+ this.log?.debug?.(`[WaterDetector] direct OpenAPI refresh failed: ${(e as Error)?.message}`)
1456
+ }
1457
+
1458
+ return undefined
1459
+ }
1460
+
1461
+ private async readLeakDetected(fallbackToDeviceState = true): Promise<boolean | undefined> {
1462
+ const status = await this.getOpenAPIStatus()
1463
+ if (status) {
1464
+ const leak = this.normalizeLeakDetected(status)
1465
+ if (typeof leak === 'boolean') {
1466
+ return leak
1467
+ }
1468
+ }
1469
+ if (!fallbackToDeviceState) {
1470
+ return undefined
1471
+ }
1472
+
1473
+ try {
1474
+ return this.normalizeLeakDetected(await this.getState())
1475
+ } catch (e) {
1476
+ this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
1477
+ }
1478
+
1479
+ return undefined
1480
+ }
1481
+
1482
+ private async readBatteryLevel(): Promise<number | undefined> {
1483
+ const status = await this.getOpenAPIStatus()
1484
+ if (status) {
1485
+ const battery = this.normalizeBatteryLevel(status)
1486
+ if (typeof battery === 'number') {
1487
+ return battery
1488
+ }
1489
+ }
1490
+
1491
+ return undefined
1492
+ }
1493
+
1494
+ private async _refreshLeakState(fallbackToDeviceState = true): Promise<void> {
1495
+ if (this._leakRefreshing) {
1496
+ return
1497
+ }
1498
+ this._leakRefreshing = true
1499
+ try {
1500
+ const leakDetected = await this.readLeakDetected(fallbackToDeviceState)
1501
+ if (typeof leakDetected === 'boolean') {
1502
+ this.lastLeakDetected = leakDetected
1503
+ this._leakRefreshTs = Date.now()
1504
+ }
1505
+ } catch (e) {
1506
+ this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
1507
+ } finally {
1508
+ this._leakRefreshing = false
1509
+ }
1510
+ }
1511
+
1512
+ private async _refreshBattery(): Promise<void> {
1513
+ if (this._batteryRefreshing) {
1514
+ return
1515
+ }
1516
+ this._batteryRefreshing = true
1517
+ try {
1518
+ const battery = await this.readBatteryLevel()
1519
+ if (typeof battery === 'number') {
1520
+ this.lastBatteryLevel = battery
1521
+ this._batteryRefreshTs = Date.now()
1522
+ }
1523
+ } catch (e) {
1524
+ this.log?.debug?.(`[WaterDetector] battery refresh failed: ${(e as Error)?.message}`)
1525
+ } finally {
1526
+ this._batteryRefreshing = false
1527
+ }
1528
+ }
1529
+
1530
+ private getLeakDetectedFast(api: any): number {
1531
+ if (Date.now() - this._leakRefreshTs >= this.LEAK_REFRESH_TTL_MS) {
1532
+ this._refreshLeakState().catch(() => undefined)
1533
+ }
1534
+ if (typeof this.lastLeakDetected === 'boolean') {
1535
+ return this.lastLeakDetected ? 1 : 0
1536
+ }
1537
+ throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
1538
+ }
1539
+
1540
+ // Returns the cached battery level immediately and triggers a background
1541
+ // refresh if the cache is stale. Throws HapStatusError on true cold start
1542
+ // so HomeKit shows "Not Available" rather than fabricating a misleading
1543
+ // value or timing out (which surfaces as 0% in the Home app).
1544
+ private getBatteryFast(api: any): number {
1545
+ if (Date.now() - this._batteryRefreshTs >= this.BATTERY_REFRESH_TTL_MS) {
1546
+ this._refreshBattery().catch(() => undefined)
1547
+ }
1548
+ if (typeof this.lastBatteryLevel === 'number') {
1549
+ return this.lastBatteryLevel
1550
+ }
1551
+ throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
1552
+ }
1553
+
1309
1554
  createHAPAccessory(api: any) {
1310
1555
  return {
1311
1556
  services: [
@@ -1313,11 +1558,25 @@ export class WaterDetectorDevice extends GenericDevice {
1313
1558
  type: 'LeakSensor',
1314
1559
  characteristics: {
1315
1560
  LeakDetected: {
1316
- get: async () => {
1317
- const s = await this.getState()
1318
- return s && s.leak ? 1 : 0
1561
+ get: () => this.getLeakDetectedFast(api),
1562
+ },
1563
+ },
1564
+ },
1565
+ {
1566
+ type: 'Battery',
1567
+ characteristics: {
1568
+ BatteryLevel: {
1569
+ get: () => this.getBatteryFast(api),
1570
+ },
1571
+ StatusLowBattery: {
1572
+ get: () => {
1573
+ const b = this.getBatteryFast(api)
1574
+ return b < 20 ? 1 : 0
1319
1575
  },
1320
1576
  },
1577
+ ChargingState: {
1578
+ get: () => 2,
1579
+ },
1321
1580
  },
1322
1581
  },
1323
1582
  ],
@@ -4,7 +4,7 @@ import { RequestError } from '@homebridge/plugin-ui-utils'
4
4
  import fs from 'node:fs/promises'
5
5
 
6
6
  import { isValidDeviceType } from '../../device-types.js'
7
- import { getAllDevices, isV4Config, SWITCHBOT_PLATFORM_REGEX } from '../utils/config-parser.js'
7
+ import { getAllDevices, SWITCHBOT_PLATFORM_REGEX } from '../utils/config-parser.js'
8
8
  import { validateAndMigrateDeviceType } from '../utils/device-migration.js'
9
9
  import { uiLog } from '../utils/logger.js'
10
10
 
@@ -31,7 +31,6 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
31
31
  typeValidationWarning?: string
32
32
  }> = []
33
33
  const invalidTypeDevices: Array<{ name: string, type: string, suggestion?: string }> = []
34
- let v4ConfigDetected = false
35
34
 
36
35
  const platforms = Array.isArray(cfg.platforms) ? cfg.platforms : []
37
36
  for (const p of platforms) {
@@ -42,12 +41,6 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
42
41
  continue
43
42
  }
44
43
 
45
- // Detect legacy v4 config format
46
- if (isV4Config(p)) {
47
- v4ConfigDetected = true
48
- uiLog.warn('Detected legacy v4 config format (credentials/options.devices). This config is not compatible with v5+. Please migrate to the v5 config format.')
49
- }
50
-
51
44
  const devices = getAllDevices(p)
52
45
  for (const d of devices) {
53
46
  const id = d.deviceId ?? d.id
@@ -101,12 +94,11 @@ export function registerConfigEndpoints(server: HomebridgePluginUiServer) {
101
94
  )
102
95
  }
103
96
 
104
- uiLog.info(`GET /devices - Found ${found.length} devices in ${cfgPath}${invalidTypeDevices.length > 0 ? ` (${invalidTypeDevices.length} with validation warnings)` : ''}${v4ConfigDetected ? ' [v4 config detected]' : ''}`)
97
+ uiLog.info(`GET /devices - Found ${found.length} devices in ${cfgPath}${invalidTypeDevices.length > 0 ? ` (${invalidTypeDevices.length} with validation warnings)` : ''}`)
105
98
  return {
106
99
  success: true,
107
100
  data: found,
108
101
  ...(invalidTypeDevices.length > 0 && { validationWarnings: invalidTypeDevices }),
109
- ...(v4ConfigDetected && { v4ConfigDetected: true }),
110
102
  }
111
103
  } catch (e) {
112
104
  const msg = e instanceof Error ? e.message : String(e)
@@ -480,64 +480,4 @@ ul.device-grid {
480
480
 
481
481
  .discovery-scanning-pulse {
482
482
  animation: pulse 1.5s ease-in-out infinite;
483
- }
484
- /* v4 config incompatibility warning banner */
485
- .v4-config-warning {
486
- background: #7c3015;
487
- color: #fff;
488
- border: 1px solid #a04020;
489
- border-radius: 8px;
490
- padding: 14px 18px;
491
- margin-bottom: 18px;
492
- font-size: 13px;
493
- line-height: 1.5;
494
- }
495
-
496
- .v4-config-warning strong {
497
- display: block;
498
- font-size: 15px;
499
- margin-bottom: 8px;
500
- }
501
-
502
- .v4-config-warning p {
503
- margin: 6px 0;
504
- }
505
-
506
- .v4-config-warning ul {
507
- margin: 6px 0 6px 18px;
508
- padding: 0;
509
- }
510
-
511
- .v4-config-warning li {
512
- margin-bottom: 3px;
513
- }
514
-
515
- .v4-config-warning code {
516
- background: rgba(255, 255, 255, 0.15);
517
- border-radius: 3px;
518
- padding: 1px 4px;
519
- font-size: 12px;
520
- color: #ffd;
521
- }
522
-
523
- .v4-config-warning a {
524
- color: #ffd;
525
- text-decoration: underline;
526
- }
527
-
528
- @media (prefers-color-scheme: light) {
529
- .v4-config-warning {
530
- background: #fff3cd;
531
- color: #7c3015;
532
- border-color: #f0ad4e;
533
- }
534
-
535
- .v4-config-warning code {
536
- background: rgba(124, 48, 21, 0.1);
537
- color: #7c3015;
538
- }
539
-
540
- .v4-config-warning a {
541
- color: #7c3015;
542
- }
543
- }
483
+ }