@switchbot/homebridge-switchbot 5.1.0-beta.9 → 5.1.1

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 (143) hide show
  1. package/.github/copilot-instructions.md +0 -63
  2. package/.github/scripts/compute-version-override.cjs +51 -0
  3. package/.github/scripts/normalize-changelog-header.cjs +1 -1
  4. package/.github/scripts/npm-version-script-esm-auto.js +1 -1
  5. package/.github/scripts/resolve-version-bump-esm.js +1 -1
  6. package/CHANGELOG.md +32 -9
  7. package/MIGRATION.md +0 -5
  8. package/README.md +0 -25
  9. package/config.schema.json +0 -11
  10. package/dist/{Platform.HAP.d.ts → SwitchBotHAPPlatform.d.ts} +16 -2
  11. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -0
  12. package/dist/{Platform.HAP.js → SwitchBotHAPPlatform.js} +111 -40
  13. package/dist/SwitchBotHAPPlatform.js.map +1 -0
  14. package/dist/{Platform.Matter.d.ts → SwitchBotMatterPlatform.d.ts} +2 -4
  15. package/dist/SwitchBotMatterPlatform.d.ts.map +1 -0
  16. package/dist/{Platform.Matter.js → SwitchBotMatterPlatform.js} +6 -62
  17. package/dist/SwitchBotMatterPlatform.js.map +1 -0
  18. package/dist/device-types.d.ts.map +1 -1
  19. package/dist/device-types.js +10 -0
  20. package/dist/device-types.js.map +1 -1
  21. package/dist/deviceFactory.d.ts.map +1 -1
  22. package/dist/deviceFactory.js +10 -16
  23. package/dist/deviceFactory.js.map +1 -1
  24. package/dist/devices/genericDevice.d.ts +63 -5
  25. package/dist/devices/genericDevice.d.ts.map +1 -1
  26. package/dist/devices/genericDevice.js +251 -11
  27. package/dist/devices/genericDevice.js.map +1 -1
  28. package/dist/homebridge-ui/device-types.js +10 -0
  29. package/dist/homebridge-ui/device-types.js.map +1 -1
  30. package/dist/homebridge-ui/endpoints/config.d.ts.map +1 -1
  31. package/dist/homebridge-ui/endpoints/config.js +2 -9
  32. package/dist/homebridge-ui/endpoints/config.js.map +1 -1
  33. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js +2 -9
  34. package/dist/homebridge-ui/homebridge-ui/endpoints/config.js.map +1 -1
  35. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js +0 -8
  36. package/dist/homebridge-ui/homebridge-ui/utils/config-parser.js.map +1 -1
  37. package/dist/homebridge-ui/public/css/styles.css +1 -61
  38. package/dist/homebridge-ui/public/index.html +2 -61
  39. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
  40. package/dist/homebridge-ui/public/js/advanced-settings.js +3 -4
  41. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
  42. package/dist/homebridge-ui/public/js/advanced-settings.ts +3 -4
  43. package/dist/homebridge-ui/public/js/api.d.ts +0 -16
  44. package/dist/homebridge-ui/public/js/api.d.ts.map +1 -1
  45. package/dist/homebridge-ui/public/js/api.js +0 -48
  46. package/dist/homebridge-ui/public/js/api.js.map +1 -1
  47. package/dist/homebridge-ui/public/js/api.ts +1 -61
  48. package/dist/homebridge-ui/public/js/app.js +135 -272
  49. package/dist/homebridge-ui/public/js/app.js.map +4 -4
  50. package/dist/homebridge-ui/public/js/app.ts +4 -47
  51. package/dist/homebridge-ui/public/js/credentials.d.ts.map +1 -1
  52. package/dist/homebridge-ui/public/js/credentials.js +5 -11
  53. package/dist/homebridge-ui/public/js/credentials.js.map +1 -1
  54. package/dist/homebridge-ui/public/js/credentials.ts +5 -11
  55. package/dist/homebridge-ui/public/js/devices.d.ts.map +1 -1
  56. package/dist/homebridge-ui/public/js/devices.js +0 -6
  57. package/dist/homebridge-ui/public/js/devices.js.map +1 -1
  58. package/dist/homebridge-ui/public/js/devices.ts +0 -6
  59. package/dist/homebridge-ui/public/js/modals.d.ts +0 -1
  60. package/dist/homebridge-ui/public/js/modals.d.ts.map +1 -1
  61. package/dist/homebridge-ui/public/js/modals.js +0 -53
  62. package/dist/homebridge-ui/public/js/modals.js.map +1 -1
  63. package/dist/homebridge-ui/public/js/modals.ts +0 -58
  64. package/dist/homebridge-ui/public/js/render.d.ts.map +1 -1
  65. package/dist/homebridge-ui/public/js/render.js +1 -2
  66. package/dist/homebridge-ui/public/js/render.js.map +1 -1
  67. package/dist/homebridge-ui/public/js/render.ts +1 -2
  68. package/dist/homebridge-ui/settings.js.map +1 -1
  69. package/dist/homebridge-ui/utils/config-parser.d.ts +0 -8
  70. package/dist/homebridge-ui/utils/config-parser.d.ts.map +1 -1
  71. package/dist/homebridge-ui/utils/config-parser.js +0 -8
  72. package/dist/homebridge-ui/utils/config-parser.js.map +1 -1
  73. package/dist/index.js +2 -2
  74. package/dist/index.js.map +1 -1
  75. package/dist/settings.d.ts +0 -11
  76. package/dist/settings.d.ts.map +1 -1
  77. package/dist/settings.js.map +1 -1
  78. package/dist/utils.d.ts +0 -5
  79. package/dist/utils.d.ts.map +1 -1
  80. package/dist/utils.js +0 -27
  81. package/dist/utils.js.map +1 -1
  82. package/docs/assets/highlight.css +16 -16
  83. package/docs/assets/main.js +1 -1
  84. package/docs/assets/style.css +18 -3
  85. package/docs/hierarchy.html +26 -1
  86. package/docs/index.html +70 -23
  87. package/docs/modules.html +33 -1
  88. package/docs/variables/default.html +46 -3
  89. package/package.json +22 -15
  90. package/scripts/build-ui.js +0 -30
  91. package/src/{Platform.HAP.ts → SwitchBotHAPPlatform.ts} +112 -39
  92. package/src/{Platform.Matter.ts → SwitchBotMatterPlatform.ts} +8 -63
  93. package/src/device-types.ts +11 -0
  94. package/src/deviceFactory.ts +10 -15
  95. package/src/devices/genericDevice.ts +271 -12
  96. package/src/homebridge-ui/endpoints/config.ts +2 -10
  97. package/src/homebridge-ui/public/css/styles.css +1 -61
  98. package/src/homebridge-ui/public/index.html +0 -59
  99. package/src/homebridge-ui/public/js/advanced-settings.ts +3 -4
  100. package/src/homebridge-ui/public/js/api.ts +1 -61
  101. package/src/homebridge-ui/public/js/app.ts +4 -47
  102. package/src/homebridge-ui/public/js/credentials.ts +5 -11
  103. package/src/homebridge-ui/public/js/devices.ts +0 -6
  104. package/src/homebridge-ui/public/js/modals.ts +0 -58
  105. package/src/homebridge-ui/public/js/render.ts +1 -2
  106. package/src/homebridge-ui/utils/config-parser.ts +0 -9
  107. package/src/index.ts +2 -2
  108. package/src/settings.ts +0 -12
  109. package/src/utils.ts +0 -31
  110. package/test/device/deviceFactory.spec.ts +9 -0
  111. package/test/device/getstate-cache.spec.ts +62 -0
  112. package/test/device/getstate-returns-status.spec.ts +89 -0
  113. package/test/device/model-identities.spec.ts +45 -0
  114. package/test/device/water-detector-battery.spec.ts +81 -0
  115. package/test/device/water-detector-leak.spec.ts +43 -0
  116. package/test/hap-push-updates.spec.ts +150 -0
  117. package/test/platform/accessory-restore.spec.ts +2 -2
  118. package/test/platform/matter-childbridge.spec.ts +1 -1
  119. package/test/platform/matter-integration.spec.ts +1 -51
  120. package/test/platform/platform-edge.spec.ts +2 -2
  121. package/test/platform/platform.integration.spec.ts +1 -1
  122. package/test/utils/utils.spec.ts +0 -15
  123. package/.github/npm-version-script-esm.js +0 -97
  124. package/dist/Platform.HAP.d.ts.map +0 -1
  125. package/dist/Platform.HAP.js.map +0 -1
  126. package/dist/Platform.Matter.d.ts.map +0 -1
  127. package/dist/Platform.Matter.js.map +0 -1
  128. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js +0 -23
  129. package/dist/homebridge-ui/homebridge-ui/utils/v4-detection.js.map +0 -1
  130. package/dist/homebridge-ui/public/js/legacy-view.d.ts +0 -3
  131. package/dist/homebridge-ui/public/js/legacy-view.d.ts.map +0 -1
  132. package/dist/homebridge-ui/public/js/legacy-view.js +0 -45
  133. package/dist/homebridge-ui/public/js/legacy-view.js.map +0 -1
  134. package/dist/homebridge-ui/public/js/legacy-view.ts +0 -107
  135. package/dist/homebridge-ui/utils/v4-detection.d.ts +0 -12
  136. package/dist/homebridge-ui/utils/v4-detection.d.ts.map +0 -1
  137. package/dist/homebridge-ui/utils/v4-detection.js +0 -23
  138. package/dist/homebridge-ui/utils/v4-detection.js.map +0 -1
  139. package/src/device-types.js +0 -246
  140. package/src/homebridge-ui/public/js/legacy-view.js +0 -45
  141. package/src/homebridge-ui/public/js/legacy-view.ts +0 -107
  142. package/src/homebridge-ui/utils/v4-detection.ts +0 -23
  143. package/test/homebridge-ui/config-parser.spec.ts +0 -61
@@ -64,6 +64,9 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
64
64
  'meterplus': MeterDevice,
65
65
  'meterpro': MeterDevice,
66
66
  'meterpro(co2)': MeterDevice,
67
+ 'meter plus': MeterDevice,
68
+ 'meter pro': MeterDevice,
69
+ 'meter pro (co2)': MeterDevice,
67
70
  'walletfinder': WalletFinderDevice,
68
71
  'plug': PlugDevice,
69
72
  'plug mini (eu)': PlugMiniDevice,
@@ -109,6 +112,7 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
109
112
  'meter': MeterDevice,
110
113
  'meter plus (jp)': MeterDevice,
111
114
  'water detector': WaterDetectorDevice,
115
+ 'waterdetector': WaterDetectorDevice,
112
116
  'smart fan': SmartFanDevice,
113
117
  'strip light': StripLightDevice,
114
118
  'wosweeper': WoSweeperDevice,
@@ -137,7 +141,8 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
137
141
 
138
142
  function classForType(type: string) {
139
143
  const rawKey = (type || '').toLowerCase()
140
- const key = DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey
144
+ // Normalised types are canonical display names; class map keys are lowercase
145
+ const key = (DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey).toLowerCase()
141
146
  return DEVICE_CLASS_MAP[key] ?? GenericDevice
142
147
  }
143
148
 
@@ -176,20 +181,10 @@ export async function createDevice(opts: DeviceOptions, cfg: SwitchBotPluginConf
176
181
  const device = new DeviceCtor(deviceOpts, mergedCfg)
177
182
  await device.init()
178
183
 
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
- }
184
+ // No getState() override here: the device's own implementation already
185
+ // prefers the client-backed lookup, and it converts the result into a status.
186
+ // An override that returned the client device directly shadowed it, and that
187
+ // object carries no readings.
193
188
 
194
189
  // Provide accessory factory based on platform selection
195
190
  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
+ }
@@ -9,57 +9,8 @@
9
9
  </head>
10
10
 
11
11
  <body>
12
-
13
12
  <h1>🤖 SwitchBot Configuration</h1>
14
13
 
15
- <!-- ...existing code... -->
16
-
17
- <!-- Centered legacy view button at the bottom -->
18
- <div class="centered-toggle-btn">
19
- <button id="legacyViewBtn">Legacy View</button>
20
- <button id="dynamicViewBtn" style="display: none;">Dynamic View</button>
21
- </div>
22
- <style>
23
- .centered-toggle-btn {
24
- display: flex;
25
- flex-direction: row;
26
- justify-content: center;
27
- align-items: center;
28
- margin-top: 32px;
29
- margin-bottom: 12px;
30
- gap: 12px;
31
- }
32
- #legacyViewBtn, #dynamicViewBtn {
33
- font-size: 12px;
34
- padding: 4px 10px;
35
- }
36
- </style>
37
-
38
- <div id="dynamicUiContainer">
39
-
40
- <!-- v4 config incompatibility warning – shown dynamically by app.js -->
41
- <div id="v4ConfigWarning" class="v4-config-warning" style="display: none">
42
- <strong>⚠️ Incompatible v4 Configuration Detected</strong>
43
- <p>
44
- Your config.json appears to be in the <strong>v4 format</strong> (using <code>credentials</code> and
45
- <code>options.devices</code> fields). This format is <strong>not compatible with v5 or newer</strong> of the
46
- SwitchBot plugin. Devices will not be recognised until the config is updated.
47
- </p>
48
- <p>
49
- Please update your config to the v5 format:
50
- </p>
51
- <ul>
52
- <li>Move <code>credentials.token</code> → <code>openApiToken</code> (top level)</li>
53
- <li>Move <code>credentials.secret</code> → <code>openApiSecret</code> (top level)</li>
54
- <li>Move <code>options.devices</code> → <code>devices</code> (top level)</li>
55
- <li>Rename per-device <code>connectionType</code> → <code>connectionPreference</code></li>
56
- <li>Remove the <code>credentials</code> and <code>options</code> wrapper objects</li>
57
- </ul>
58
- <p>
59
- See the <a href="https://github.com/OpenWonderLabs/homebridge-switchbot/blob/latest/MIGRATION.md" target="_blank" rel="noopener">Migration Guide</a> for full details.
60
- </p>
61
- </div>
62
-
63
14
  <div class="card compact">
64
15
  <h2>API Credentials</h2>
65
16
  <div class="credentials-row">
@@ -253,18 +204,8 @@
253
204
  </form>
254
205
  </div>
255
206
 
256
- </div> <!-- end dynamicUiContainer -->
257
-
258
- <div id="legacyViewContainer" style="display: none;">
259
- <div class="card compact">
260
- <h2>Legacy Config View</h2>
261
- <div id="legacyConfigForm"></div>
262
- </div>
263
- </div>
264
-
265
207
  <script type="module" src="js/app.js"></script>
266
208
  <script type="module" src="js/advanced-settings.js"></script>
267
- <script type="module" src="js/legacy-view.js"></script>
268
209
  </body>
269
210
 
270
211
  </html>
@@ -46,11 +46,10 @@ async function saveAdvancedSettings(): Promise<void> {
46
46
  if (!Array.isArray(pluginConfigBlocks) || !pluginConfigBlocks.length) {
47
47
  throw new Error('No plugin config blocks returned from Homebridge')
48
48
  }
49
- // Find or add the SwitchBot config block
50
- let idx = pluginConfigBlocks.findIndex(c => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
49
+ // Find the SwitchBot config block
50
+ const idx = pluginConfigBlocks.findIndex(c => (c.platform || c.name || '').toLowerCase().includes('switchbot'))
51
51
  if (idx === -1) {
52
- pluginConfigBlocks.push({ platform: 'SwitchBot' })
53
- idx = pluginConfigBlocks.length - 1
52
+ throw new Error('SwitchBot config not found')
54
53
  }
55
54
  const config = pluginConfigBlocks[idx]
56
55
  // Update config values from UI
@@ -1,6 +1,5 @@
1
1
  // Fetch the list of configured devices from the Homebridge UI API
2
2
  import { isValidDeviceType, normalizeDeviceType } from '../../../device-types.js'
3
- import { isV4Config } from '../../utils/v4-detection.js'
4
3
  import './types.js'
5
4
  import { uiLog } from './logger.js'
6
5
  import { toastError } from './toast.js'
@@ -23,35 +22,6 @@ export async function fetchDevices(): Promise<any[]> {
23
22
  }
24
23
  }
25
24
 
26
- /**
27
- * Detect whether the current plugin config is in the legacy v4 format.
28
- *
29
- * v4 configs stored credentials under a `credentials` sub-object and devices
30
- * under `options.devices`. v5+ flattened these to `openApiToken`,
31
- * `openApiSecret`, and a root-level `devices` array.
32
- *
33
- * Returns true when a v4 config block is found, false otherwise.
34
- */
35
- export async function detectV4Config(): Promise<boolean> {
36
- try {
37
- if (typeof homebridge.getPluginConfig !== 'function') {
38
- return false
39
- }
40
- const configArr = await homebridge.getPluginConfig()
41
- if (!Array.isArray(configArr)) {
42
- return false
43
- }
44
- const config = configArr.find(isSwitchBotPlatformConfig)
45
- if (!config || typeof config !== 'object') {
46
- return false
47
- }
48
- return isV4Config(config)
49
- } catch (e) {
50
- uiLog.warn('Error detecting v4 config:', e)
51
- return false
52
- }
53
- }
54
-
55
25
  /**
56
26
  * Validate and auto-correct device types in the config array before saving.
57
27
  * Returns an array of errors for devices that cannot be fixed.
@@ -219,19 +189,7 @@ export async function addDevice(
219
189
  deviceId: string,
220
190
  name: string,
221
191
  type: string,
222
- options?: {
223
- address?: string
224
- model?: string
225
- rssi?: number
226
- connectionPreference?: string
227
- externalPublishProtocol?: string
228
- room?: string
229
- encryptionKey?: string
230
- keyId?: string
231
- refreshRate?: number
232
- blePollingEnabled?: boolean
233
- blePollIntervalMs?: number
234
- },
192
+ options?: { address?: string, model?: string, rssi?: number, encryptionKey?: string, keyId?: string },
235
193
  ): Promise<any> {
236
194
  if (typeof homebridge.getPluginConfig !== 'function' || typeof homebridge.updatePluginConfig !== 'function') {
237
195
  throw new TypeError('Homebridge UI API not available')
@@ -266,24 +224,6 @@ export async function addDevice(
266
224
  if (options?.keyId) {
267
225
  newDevice.keyId = options.keyId
268
226
  }
269
- if (options?.connectionPreference) {
270
- newDevice.connectionPreference = options.connectionPreference
271
- }
272
- if (options?.externalPublishProtocol) {
273
- newDevice.externalPublishProtocol = options.externalPublishProtocol
274
- }
275
- if (options?.room) {
276
- newDevice.room = options.room
277
- }
278
- if (typeof options?.refreshRate === 'number' && Number.isFinite(options.refreshRate)) {
279
- newDevice.refreshRate = options.refreshRate
280
- }
281
- if (typeof options?.blePollingEnabled === 'boolean') {
282
- newDevice.blePollingEnabled = options.blePollingEnabled
283
- }
284
- if (typeof options?.blePollIntervalMs === 'number' && Number.isFinite(options.blePollIntervalMs)) {
285
- newDevice.blePollIntervalMs = options.blePollIntervalMs
286
- }
287
227
  config.devices.push(newDevice)
288
228
  await homebridge.updatePluginConfig(configArr)
289
229
  if (typeof homebridge.savePluginConfig === 'function') {