@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.
- package/.github/scripts/npm-version-script-esm-auto.js +1 -1
- package/.github/scripts/resolve-version-bump-esm.js +1 -1
- package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
- package/dist/SwitchBotHAPPlatform.js +49 -2
- package/dist/SwitchBotHAPPlatform.js.map +1 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +1 -0
- package/dist/deviceFactory.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +52 -5
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +202 -8
- package/dist/devices/genericDevice.js.map +1 -1
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/SwitchBotHAPPlatform.ts +47 -2
- package/src/deviceFactory.ts +1 -0
- package/src/devices/genericDevice.ts +218 -8
- package/test/device/deviceFactory.spec.ts +9 -0
- package/test/device/water-detector-battery.spec.ts +81 -0
- package/test/device/water-detector-leak.spec.ts +43 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
501
|
-
return
|
|
534
|
+
await this.getPositionForHomeKit()
|
|
535
|
+
return this.lastTargetPosition
|
|
502
536
|
},
|
|
503
537
|
set: async (v: any) => {
|
|
504
|
-
|
|
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:
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
+
})
|