@switchbot/homebridge-switchbot 5.0.3 → 5.0.4

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.
@@ -483,6 +483,38 @@ export class GenericDevice extends DeviceBase {
483
483
  export class BotDevice extends GenericDevice {}
484
484
 
485
485
  export class CurtainDevice extends GenericDevice {
486
+ private lastKnownPosition = 0
487
+ private lastTargetPosition = 0
488
+ private positionState = 2
489
+ private preferLocalPositionUntil = 0
490
+
491
+ private clampHomeKitPosition(position: number): number {
492
+ return Math.max(0, Math.min(100, Math.round(Number(position))))
493
+ }
494
+
495
+ private toHomeKitPosition(switchBotPosition: number): number {
496
+ return 100 - this.clampHomeKitPosition(switchBotPosition)
497
+ }
498
+
499
+ private toSwitchBotPosition(homeKitPosition: number): number {
500
+ return 100 - this.clampHomeKitPosition(homeKitPosition)
501
+ }
502
+
503
+ private async getPositionForHomeKit(): Promise<number> {
504
+ if (Date.now() < this.preferLocalPositionUntil) {
505
+ return this.lastKnownPosition
506
+ }
507
+ const state = await Promise.race([
508
+ this.getState(),
509
+ new Promise(resolve => setTimeout(resolve, 1000)),
510
+ ])
511
+ if (typeof state?.position === 'number') {
512
+ this.lastKnownPosition = this.toHomeKitPosition(Number(state.position))
513
+ this.lastTargetPosition = this.lastKnownPosition
514
+ }
515
+ return this.lastKnownPosition
516
+ }
517
+
486
518
  createHAPAccessory(api: any) {
487
519
  return {
488
520
  services: [
@@ -491,18 +523,27 @@ export class CurtainDevice extends GenericDevice {
491
523
  characteristics: {
492
524
  CurrentPosition: {
493
525
  get: async () => {
494
- const s = await this.getState()
495
- return typeof s.position === 'number' ? s.position : 0
526
+ return this.getPositionForHomeKit()
496
527
  },
497
528
  },
529
+ PositionState: {
530
+ get: async () => this.positionState,
531
+ },
498
532
  TargetPosition: {
499
533
  get: async () => {
500
- const s = await this.getState()
501
- return typeof s.position === 'number' ? s.position : 0
534
+ await this.getPositionForHomeKit()
535
+ return this.lastTargetPosition
502
536
  },
503
537
  set: async (v: any) => {
504
- await this.setState({ position: Number(v) })
538
+ const position = this.clampHomeKitPosition(Number(v))
539
+ this.lastTargetPosition = position
540
+ this.positionState = position > this.lastKnownPosition ? 1 : position < this.lastKnownPosition ? 0 : 2
541
+ await this.setState({ position: this.toSwitchBotPosition(position) })
542
+ this.lastKnownPosition = position
543
+ this.preferLocalPositionUntil = Date.now() + 30000
544
+ this.positionState = 2
505
545
  },
546
+ refreshAfterSet: ['CurrentPosition', 'TargetPosition', 'PositionState'],
506
547
  },
507
548
  },
508
549
  },
@@ -1306,6 +1347,161 @@ export class MeterDevice extends GenericDevice {
1306
1347
  }
1307
1348
 
1308
1349
  export class WaterDetectorDevice extends GenericDevice {
1350
+ private _leakRefreshing = false
1351
+ private _leakRefreshTs = 0
1352
+ private lastLeakDetected?: boolean
1353
+ private readonly LEAK_REFRESH_TTL_MS = 30_000
1354
+
1355
+ private lastBatteryLevel?: number
1356
+ private _batteryRefreshing = false
1357
+ private _batteryRefreshTs = 0
1358
+ private readonly BATTERY_REFRESH_TTL_MS = 30_000
1359
+
1360
+ async init(): Promise<void> {
1361
+ await super.init()
1362
+ await this._refreshLeakState(!this.client)
1363
+ await this._refreshBattery()
1364
+ }
1365
+
1366
+ private normalizeLeakDetected(state: any): boolean | undefined {
1367
+ if (typeof state?.leak === 'boolean') {
1368
+ return state.leak
1369
+ }
1370
+ if (typeof state?.waterLeakDetected === 'boolean') {
1371
+ return state.waterLeakDetected
1372
+ }
1373
+ if (typeof state?.body?.waterLeakDetected === 'boolean') {
1374
+ return state.body.waterLeakDetected
1375
+ }
1376
+ const status = state?.status ?? state?.body?.status
1377
+ if (typeof status === 'number') {
1378
+ return status === 1
1379
+ }
1380
+ if (typeof status === 'string') {
1381
+ return ['1', 'leak', 'leaked', 'detected', 'water_leak_detected'].includes(status.toLowerCase())
1382
+ }
1383
+ return undefined
1384
+ }
1385
+
1386
+ private normalizeBatteryLevel(state: any): number | undefined {
1387
+ const raw = state?.battery ?? state?.body?.battery
1388
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
1389
+ return undefined
1390
+ }
1391
+ return Math.max(0, Math.min(100, raw))
1392
+ }
1393
+
1394
+ private async getOpenAPIStatus(): Promise<any> {
1395
+ const token = this.cfg?.openApiToken
1396
+ const secret = this.cfg?.openApiSecret
1397
+ if (!token || !secret) {
1398
+ return undefined
1399
+ }
1400
+
1401
+ try {
1402
+ const { OpenAPIClient } = await import('node-switchbot')
1403
+ const apiClient = new OpenAPIClient(token, secret)
1404
+ return await apiClient.getStatus(this.opts.id)
1405
+ } catch (e) {
1406
+ this.log?.debug?.(`[WaterDetector] direct OpenAPI refresh failed: ${(e as Error)?.message}`)
1407
+ }
1408
+
1409
+ return undefined
1410
+ }
1411
+
1412
+ private async readLeakDetected(fallbackToDeviceState = true): Promise<boolean | undefined> {
1413
+ const status = await this.getOpenAPIStatus()
1414
+ if (status) {
1415
+ const leak = this.normalizeLeakDetected(status)
1416
+ if (typeof leak === 'boolean') {
1417
+ return leak
1418
+ }
1419
+ }
1420
+ if (!fallbackToDeviceState) {
1421
+ return undefined
1422
+ }
1423
+
1424
+ try {
1425
+ return this.normalizeLeakDetected(await this.getState())
1426
+ } catch (e) {
1427
+ this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
1428
+ }
1429
+
1430
+ return undefined
1431
+ }
1432
+
1433
+ private async readBatteryLevel(): Promise<number | undefined> {
1434
+ const status = await this.getOpenAPIStatus()
1435
+ if (status) {
1436
+ const battery = this.normalizeBatteryLevel(status)
1437
+ if (typeof battery === 'number') {
1438
+ return battery
1439
+ }
1440
+ }
1441
+
1442
+ return undefined
1443
+ }
1444
+
1445
+ private async _refreshLeakState(fallbackToDeviceState = true): Promise<void> {
1446
+ if (this._leakRefreshing) {
1447
+ return
1448
+ }
1449
+ this._leakRefreshing = true
1450
+ try {
1451
+ const leakDetected = await this.readLeakDetected(fallbackToDeviceState)
1452
+ if (typeof leakDetected === 'boolean') {
1453
+ this.lastLeakDetected = leakDetected
1454
+ this._leakRefreshTs = Date.now()
1455
+ }
1456
+ } catch (e) {
1457
+ this.log?.debug?.(`[WaterDetector] leak refresh failed: ${(e as Error)?.message}`)
1458
+ } finally {
1459
+ this._leakRefreshing = false
1460
+ }
1461
+ }
1462
+
1463
+ private async _refreshBattery(): Promise<void> {
1464
+ if (this._batteryRefreshing) {
1465
+ return
1466
+ }
1467
+ this._batteryRefreshing = true
1468
+ try {
1469
+ const battery = await this.readBatteryLevel()
1470
+ if (typeof battery === 'number') {
1471
+ this.lastBatteryLevel = battery
1472
+ this._batteryRefreshTs = Date.now()
1473
+ }
1474
+ } catch (e) {
1475
+ this.log?.debug?.(`[WaterDetector] battery refresh failed: ${(e as Error)?.message}`)
1476
+ } finally {
1477
+ this._batteryRefreshing = false
1478
+ }
1479
+ }
1480
+
1481
+ private getLeakDetectedFast(api: any): number {
1482
+ if (Date.now() - this._leakRefreshTs >= this.LEAK_REFRESH_TTL_MS) {
1483
+ this._refreshLeakState().catch(() => undefined)
1484
+ }
1485
+ if (typeof this.lastLeakDetected === 'boolean') {
1486
+ return this.lastLeakDetected ? 1 : 0
1487
+ }
1488
+ throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
1489
+ }
1490
+
1491
+ // Returns the cached battery level immediately and triggers a background
1492
+ // refresh if the cache is stale. Throws HapStatusError on true cold start
1493
+ // so HomeKit shows "Not Available" rather than fabricating a misleading
1494
+ // value or timing out (which surfaces as 0% in the Home app).
1495
+ private getBatteryFast(api: any): number {
1496
+ if (Date.now() - this._batteryRefreshTs >= this.BATTERY_REFRESH_TTL_MS) {
1497
+ this._refreshBattery().catch(() => undefined)
1498
+ }
1499
+ if (typeof this.lastBatteryLevel === 'number') {
1500
+ return this.lastBatteryLevel
1501
+ }
1502
+ throw new api.hap.HapStatusError(api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE)
1503
+ }
1504
+
1309
1505
  createHAPAccessory(api: any) {
1310
1506
  return {
1311
1507
  services: [
@@ -1313,11 +1509,25 @@ export class WaterDetectorDevice extends GenericDevice {
1313
1509
  type: 'LeakSensor',
1314
1510
  characteristics: {
1315
1511
  LeakDetected: {
1316
- get: async () => {
1317
- const s = await this.getState()
1318
- return s && s.leak ? 1 : 0
1512
+ get: () => this.getLeakDetectedFast(api),
1513
+ },
1514
+ },
1515
+ },
1516
+ {
1517
+ type: 'Battery',
1518
+ characteristics: {
1519
+ BatteryLevel: {
1520
+ get: () => this.getBatteryFast(api),
1521
+ },
1522
+ StatusLowBattery: {
1523
+ get: () => {
1524
+ const b = this.getBatteryFast(api)
1525
+ return b < 20 ? 1 : 0
1319
1526
  },
1320
1527
  },
1528
+ ChargingState: {
1529
+ get: () => 2,
1530
+ },
1321
1531
  },
1322
1532
  },
1323
1533
  ],
@@ -30,4 +30,13 @@ describe('createDevice', () => {
30
30
  const result = await createDevice({ id: 'xyz', type: 'UnknownType' }, dummyConfig as any, false)
31
31
  expect(result.instance.constructor.name).toBe('GenericDevice')
32
32
  })
33
+
34
+ it('should create a WaterDetectorDevice for normalized "waterdetector" type', async () => {
35
+ // normalizeTypeForMatter() returns 'waterdetector' (no space) for water detector variants.
36
+ // This regression test ensures the no-space key is mapped so devices aren't silently
37
+ // downgraded to GenericDevice (which would expose a bogus Switch service).
38
+ const result = await createDevice({ id: 'wd1', type: 'waterdetector' }, dummyConfig as any, false)
39
+ expect(result.instance).toBeDefined()
40
+ expect(result.instance.constructor.name).toBe('WaterDetectorDevice')
41
+ })
33
42
  })
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { WaterDetectorDevice } from '../../src/devices/genericDevice'
4
+
5
+ const openApiMocks = vi.hoisted(() => {
6
+ const getStatus = vi.fn()
7
+ return {
8
+ getStatus,
9
+ // Use a function implementation so the mock is constructible with `new OpenAPIClient()`
10
+ // eslint-disable-next-line prefer-arrow-callback
11
+ OpenAPIClient: vi.fn().mockImplementation(function () { return { getStatus } }),
12
+
13
+ }
14
+ })
15
+
16
+ vi.mock('node-switchbot', () => ({
17
+ OpenAPIClient: openApiMocks.OpenAPIClient,
18
+ }))
19
+
20
+ const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
21
+
22
+ const mockApi = {
23
+ hap: {
24
+ HapStatusError: class HapStatusError extends Error {
25
+ constructor(readonly status: number) {
26
+ super(`HAP ${status}`)
27
+ }
28
+ },
29
+ HAPStatus: {
30
+ SERVICE_COMMUNICATION_FAILURE: -70402,
31
+ },
32
+ },
33
+ }
34
+
35
+ function makeDevice(config: Record<string, unknown> = {}) {
36
+ return new WaterDetectorDevice(
37
+ { id: 'wd1', type: 'waterdetector', name: 'Fridge', log: mockLogger, blePollingEnabled: false },
38
+ { log: mockLogger, openApiToken: 'token', openApiSecret: 'secret', ...config } as any,
39
+ )
40
+ }
41
+
42
+ function getBatteryService(device: WaterDetectorDevice) {
43
+ const accessory = device.createHAPAccessory(mockApi)
44
+ return accessory.services.find((service: any) => service.type === 'Battery')
45
+ }
46
+
47
+ describe('water detector device battery service', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+ openApiMocks.getStatus.mockResolvedValue({ battery: 87 })
51
+ })
52
+
53
+ it('adds a Battery service to the HAP accessory descriptor', () => {
54
+ const batteryService = getBatteryService(makeDevice())
55
+
56
+ expect(batteryService).toBeDefined()
57
+ expect(batteryService.characteristics.BatteryLevel).toBeDefined()
58
+ expect(batteryService.characteristics.StatusLowBattery).toBeDefined()
59
+ expect(batteryService.characteristics.ChargingState).toBeDefined()
60
+ })
61
+
62
+ it('warms the battery cache from direct OpenAPI status', async () => {
63
+ const device = makeDevice()
64
+
65
+ await device.init()
66
+
67
+ const batteryService = getBatteryService(device)
68
+ expect(openApiMocks.OpenAPIClient).toHaveBeenCalledWith('token', 'secret')
69
+ expect(openApiMocks.getStatus).toHaveBeenCalledWith('wd1')
70
+ expect(batteryService.characteristics.BatteryLevel.get()).toBe(87)
71
+ expect(batteryService.characteristics.StatusLowBattery.get()).toBe(0)
72
+ expect(batteryService.characteristics.ChargingState.get()).toBe(2)
73
+ })
74
+
75
+ it('does not fabricate a battery level before one is cached', () => {
76
+ const device = makeDevice({ openApiSecret: undefined })
77
+ const batteryService = getBatteryService(device)
78
+
79
+ expect(() => batteryService.characteristics.BatteryLevel.get()).toThrow('HAP -70402')
80
+ })
81
+ })
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { WaterDetectorDevice } from '../../src/devices/genericDevice'
4
+
5
+ const mockLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
6
+
7
+ const mockApi = {
8
+ hap: {
9
+ HapStatusError: class HapStatusError extends Error {
10
+ constructor(public status: number) {
11
+ super(`HAP status ${status}`)
12
+ }
13
+ },
14
+ HAPStatus: {
15
+ SERVICE_COMMUNICATION_FAILURE: -70402,
16
+ },
17
+ },
18
+ }
19
+
20
+ describe('water detector device leak state', () => {
21
+ it('warms leak state and serves LeakDetected synchronously from cache', async () => {
22
+ let reads = 0
23
+ const device = new WaterDetectorDevice({ id: 'wd1', type: 'waterdetector' }, { log: mockLogger })
24
+ device.getState = async () => {
25
+ reads += 1
26
+ return { waterLeakDetected: true }
27
+ }
28
+
29
+ await device.init()
30
+ const leakDetected = device.createHAPAccessory(mockApi).services[0].characteristics.LeakDetected.get()
31
+
32
+ expect(leakDetected).toBe(1)
33
+ expect(leakDetected).not.toBeInstanceOf(Promise)
34
+ expect(reads).toBe(1)
35
+ })
36
+
37
+ it('throws a HAP communication failure instead of blocking when no leak state is cached', () => {
38
+ const device = new WaterDetectorDevice({ id: 'wd1', type: 'waterdetector' }, { log: mockLogger })
39
+ device.getState = async () => ({ waterLeakDetected: false })
40
+
41
+ expect(() => device.createHAPAccessory(mockApi).services[0].characteristics.LeakDetected.get()).toThrow('HAP status -70402')
42
+ })
43
+ })