@switchbot/homebridge-switchbot 5.1.1 → 5.1.2
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/CHANGELOG.md +9 -0
- package/README.md +2 -0
- package/config.schema.json +40 -1
- package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
- package/dist/SwitchBotHAPPlatform.js +35 -18
- package/dist/SwitchBotHAPPlatform.js.map +1 -1
- package/dist/devices/genericDevice.d.ts +10 -17
- package/dist/devices/genericDevice.d.ts.map +1 -1
- package/dist/devices/genericDevice.js +125 -20
- package/dist/devices/genericDevice.js.map +1 -1
- package/dist/homebridge-ui/public/index.html +5 -0
- package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
- package/dist/homebridge-ui/public/js/advanced-settings.js +2 -0
- package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
- package/dist/homebridge-ui/public/js/advanced-settings.ts +2 -0
- package/docs/index.html +2 -0
- package/docs/variables/default.html +1 -1
- package/package.json +1 -1
- package/src/SwitchBotHAPPlatform.ts +35 -17
- package/src/devices/genericDevice.ts +136 -20
- package/src/homebridge-ui/public/index.html +5 -0
- package/src/homebridge-ui/public/js/advanced-settings.ts +2 -0
- package/test/device/bot-hap.spec.ts +184 -0
- package/test/device/meter-co2.spec.ts +96 -0
|
@@ -529,7 +529,88 @@ export class GenericDevice extends DeviceBase {
|
|
|
529
529
|
}
|
|
530
530
|
|
|
531
531
|
// Specific device classes can extend GenericDevice for custom behavior.
|
|
532
|
-
export class BotDevice extends GenericDevice {
|
|
532
|
+
export class BotDevice extends GenericDevice {
|
|
533
|
+
private lastCommand?: { on: boolean, at: number }
|
|
534
|
+
|
|
535
|
+
createHAPAccessory(api: any): any {
|
|
536
|
+
const config = this.cfg.devices?.find((device: any) => (device.deviceId ?? device.id) === this.opts.id)
|
|
537
|
+
// Keep the old per-device type override for configurations made before v5.
|
|
538
|
+
const display = config?.botDisplay ?? config?.type ?? 'switch'
|
|
539
|
+
const pressMode = config?.mode === 'press' || config?.mode === 'multipress'
|
|
540
|
+
|| (display === 'door' && config?.mode !== 'switch')
|
|
541
|
+
|
|
542
|
+
const isOn = async () => {
|
|
543
|
+
if (pressMode) {
|
|
544
|
+
return false
|
|
545
|
+
}
|
|
546
|
+
if (this.lastCommand && Date.now() - this.lastCommand.at < 15_000) {
|
|
547
|
+
return this.lastCommand.on
|
|
548
|
+
}
|
|
549
|
+
const state = await this.getState()
|
|
550
|
+
return !!(state && (state.on === true || state.state === 'on' || state.power === 'on'))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const setOn = async (on: boolean) => {
|
|
554
|
+
if (pressMode && !on) {
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const command = pressMode ? 'press' : on ? 'turnOn' : 'turnOff'
|
|
558
|
+
const result = await this.setState({ command, parameter: 'default', commandType: 'command' })
|
|
559
|
+
if (result !== true && result?.success !== true) {
|
|
560
|
+
throw new Error(`SwitchBot Bot ${command} failed: ${result?.reason ?? 'unknown error'}`)
|
|
561
|
+
}
|
|
562
|
+
if (!pressMode) {
|
|
563
|
+
this.lastCommand = { on, at: Date.now() }
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const control = (name: string, active: number | boolean, inactive: number | boolean, requestedOn: (value: any) => boolean) => ({
|
|
568
|
+
get: async () => (await isOn()) ? active : inactive,
|
|
569
|
+
set: async (value: any) => setOn(requestedOn(value)),
|
|
570
|
+
...(pressMode ? { refreshAfterSet: [name] } : {}),
|
|
571
|
+
})
|
|
572
|
+
const position = () => ({
|
|
573
|
+
CurrentPosition: { get: async () => (await isOn()) ? 100 : 0 },
|
|
574
|
+
PositionState: { get: async () => 2 },
|
|
575
|
+
TargetPosition: control('TargetPosition', 100, 0, value => Number(value) > 0),
|
|
576
|
+
})
|
|
577
|
+
const active = (value: any) => Number(value) === 1
|
|
578
|
+
const presentations: Record<string, { type: string, characteristics: Record<string, any> }> = {
|
|
579
|
+
switch: { type: 'Switch', characteristics: { On: control('On', true, false, value => value === true) } },
|
|
580
|
+
outlet: { type: 'Outlet', characteristics: { On: control('On', true, false, value => value === true) } },
|
|
581
|
+
door: { type: 'Door', characteristics: position() },
|
|
582
|
+
window: { type: 'Window', characteristics: position() },
|
|
583
|
+
windowcovering: { type: 'WindowCovering', characteristics: position() },
|
|
584
|
+
garagedoor: { type: 'GarageDoorOpener', characteristics: {
|
|
585
|
+
CurrentDoorState: { get: async () => (await isOn()) ? 0 : 1 },
|
|
586
|
+
TargetDoorState: control('TargetDoorState', 0, 1, value => Number(value) === 0),
|
|
587
|
+
ObstructionDetected: { get: async () => false },
|
|
588
|
+
} },
|
|
589
|
+
lock: { type: 'LockMechanism', characteristics: {
|
|
590
|
+
LockCurrentState: { get: async () => (await isOn()) ? 0 : 1 },
|
|
591
|
+
LockTargetState: control('LockTargetState', 0, 1, value => Number(value) === 0),
|
|
592
|
+
} },
|
|
593
|
+
faucet: { type: 'Faucet', characteristics: { Active: control('Active', 1, 0, active) } },
|
|
594
|
+
fan: { type: 'Fanv2', characteristics: { Active: control('Active', 1, 0, active) } },
|
|
595
|
+
stateful: { type: 'StatefulProgrammableSwitch', characteristics: {
|
|
596
|
+
ProgrammableSwitchEvent: {},
|
|
597
|
+
ProgrammableSwitchOutputState: {
|
|
598
|
+
...control('ProgrammableSwitchOutputState', 1, 0, active),
|
|
599
|
+
...(pressMode ? { refreshAfterSet: ['ProgrammableSwitchOutputState'] } : {}),
|
|
600
|
+
afterSet: (value: any, service: any, hap: any) => {
|
|
601
|
+
if (!pressMode || active(value)) {
|
|
602
|
+
service.getCharacteristic(hap.Characteristic.ProgrammableSwitchEvent).sendEventNotification(0)
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
} },
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
services: [presentations[display] ?? presentations.switch],
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
533
614
|
|
|
534
615
|
export class CurtainDevice extends GenericDevice {
|
|
535
616
|
private lastKnownPosition = 0
|
|
@@ -1364,34 +1445,69 @@ export class RollerShadeDevice extends CurtainDevice {}
|
|
|
1364
1445
|
|
|
1365
1446
|
export class Hub2Device extends GenericDevice {}
|
|
1366
1447
|
|
|
1448
|
+
/** Default ppm at or above which HomeKit is told the CO2 level is abnormal. */
|
|
1449
|
+
const CO2_ABNORMAL_PPM_DEFAULT = 1000
|
|
1450
|
+
|
|
1367
1451
|
export class MeterDevice extends GenericDevice {
|
|
1452
|
+
/**
|
|
1453
|
+
* The Meter Pro (CO2) reports carbon dioxide as well. `opts.type` is 'meter'
|
|
1454
|
+
* for every meter variant, so check the configured device type.
|
|
1455
|
+
*/
|
|
1456
|
+
protected reportsCO2(): boolean {
|
|
1457
|
+
return /co2/i.test(String((this.opts as any)?.deviceType ?? ''))
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** The ppm at which HomeKit reports carbon dioxide as detected. */
|
|
1461
|
+
protected co2AbnormalThreshold(): number {
|
|
1462
|
+
const value = Number((this.cfg as any)?.co2AbnormalThreshold)
|
|
1463
|
+
return Number.isFinite(value) && value > 0 ? value : CO2_ABNORMAL_PPM_DEFAULT
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1368
1466
|
createHAPAccessory(api: any) {
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
return typeof s.temperature === 'number' ? s.temperature : 0
|
|
1378
|
-
},
|
|
1467
|
+
const services: any[] = [
|
|
1468
|
+
{
|
|
1469
|
+
type: 'TemperatureSensor',
|
|
1470
|
+
characteristics: {
|
|
1471
|
+
CurrentTemperature: {
|
|
1472
|
+
get: async () => {
|
|
1473
|
+
const s = await this.getState()
|
|
1474
|
+
return typeof s.temperature === 'number' ? s.temperature : 0
|
|
1379
1475
|
},
|
|
1380
1476
|
},
|
|
1381
1477
|
},
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
type: 'HumiditySensor',
|
|
1481
|
+
characteristics: {
|
|
1482
|
+
CurrentRelativeHumidity: {
|
|
1483
|
+
get: async () => {
|
|
1484
|
+
const s = await this.getState()
|
|
1485
|
+
return typeof s.humidity === 'number' ? s.humidity : 0
|
|
1390
1486
|
},
|
|
1391
1487
|
},
|
|
1392
1488
|
},
|
|
1393
|
-
|
|
1489
|
+
},
|
|
1490
|
+
]
|
|
1491
|
+
|
|
1492
|
+
if (this.reportsCO2()) {
|
|
1493
|
+
const co2 = async (): Promise<number> => {
|
|
1494
|
+
const s = await this.getState()
|
|
1495
|
+
return typeof s.co2 === 'number' ? s.co2 : 0
|
|
1496
|
+
}
|
|
1497
|
+
services.push({
|
|
1498
|
+
type: 'CarbonDioxideSensor',
|
|
1499
|
+
characteristics: {
|
|
1500
|
+
CarbonDioxideLevel: {
|
|
1501
|
+
get: co2,
|
|
1502
|
+
},
|
|
1503
|
+
CarbonDioxideDetected: {
|
|
1504
|
+
get: async () => ((await co2()) >= this.co2AbnormalThreshold() ? 1 : 0),
|
|
1505
|
+
},
|
|
1506
|
+
},
|
|
1507
|
+
})
|
|
1394
1508
|
}
|
|
1509
|
+
|
|
1510
|
+
return { services }
|
|
1395
1511
|
}
|
|
1396
1512
|
}
|
|
1397
1513
|
|
|
@@ -199,6 +199,11 @@
|
|
|
199
199
|
<input type="number" id="matterBatchJitter" min="0" max="300" step="1" value="0" />
|
|
200
200
|
<small>Random startup delay before the first batch to reduce synchronized spikes. Default: 0.</small>
|
|
201
201
|
</div>
|
|
202
|
+
<div class="form-group">
|
|
203
|
+
<label for="co2AbnormalThreshold">CO2 Detected Threshold (ppm)</label>
|
|
204
|
+
<input type="number" id="co2AbnormalThreshold" min="400" max="5000" step="1" value="1000" />
|
|
205
|
+
<small>Meter Pro (CO2) only. At or above this level HomeKit reports carbon dioxide as detected. Default: 1000.</small>
|
|
206
|
+
</div>
|
|
202
207
|
<button type="button" id="saveAdvancedSettingsBtn">Save Advanced Settings</button>
|
|
203
208
|
<div id="advancedSettingsStatus" style="margin-top: 8px; font-size: 12px;"></div>
|
|
204
209
|
</form>
|
|
@@ -29,6 +29,7 @@ async function loadAdvancedSettings(): Promise<void> {
|
|
|
29
29
|
;(document.getElementById('enableBLE') as HTMLInputElement).checked = config.enableBLE !== false
|
|
30
30
|
;(document.getElementById('blePollingEnabled') as HTMLInputElement).checked = config.blePollingEnabled !== false
|
|
31
31
|
;(document.getElementById('blePollIntervalMs') as HTMLInputElement).value = String(config.blePollIntervalMs ?? 600000)
|
|
32
|
+
;(document.getElementById('co2AbnormalThreshold') as HTMLInputElement).value = String(config.co2AbnormalThreshold ?? 1000)
|
|
32
33
|
} catch (e) {
|
|
33
34
|
(document.getElementById('advancedSettingsStatus') as HTMLElement).textContent = 'Failed to load settings.'
|
|
34
35
|
}
|
|
@@ -67,6 +68,7 @@ async function saveAdvancedSettings(): Promise<void> {
|
|
|
67
68
|
config.enableBLE = (document.getElementById('enableBLE') as HTMLInputElement).checked
|
|
68
69
|
config.blePollingEnabled = (document.getElementById('blePollingEnabled') as HTMLInputElement).checked
|
|
69
70
|
config.blePollIntervalMs = Number((document.getElementById('blePollIntervalMs') as HTMLInputElement).value) || 600000
|
|
71
|
+
config.co2AbnormalThreshold = Number((document.getElementById('co2AbnormalThreshold') as HTMLInputElement).value) || 1000
|
|
70
72
|
|
|
71
73
|
// Update config in memory and save to disk
|
|
72
74
|
if (typeof homebridge.updatePluginConfig === 'function') {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Accessory, Characteristic, Service, uuid } from '@homebridge/hap-nodejs'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { BotDevice } from '../../src/devices/genericDevice.js'
|
|
5
|
+
import { SwitchBotHAPPlatform } from '../../src/SwitchBotHAPPlatform.js'
|
|
6
|
+
|
|
7
|
+
const id = 'bot-door-test'
|
|
8
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
|
9
|
+
|
|
10
|
+
function bot(config: Record<string, unknown>, result: unknown = { success: true }) {
|
|
11
|
+
const setDeviceState = vi.fn(async () => result)
|
|
12
|
+
const device = new BotDevice(
|
|
13
|
+
{ id, type: 'Bot', name: 'Entrance', log, blePollingEnabled: false },
|
|
14
|
+
{ log, devices: [{ deviceId: id, configDeviceType: 'Bot', ...config }], _client: { setDeviceState } } as any,
|
|
15
|
+
)
|
|
16
|
+
return { device, setDeviceState }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const displays = [
|
|
20
|
+
['switch', 'Switch', 'On', true, false],
|
|
21
|
+
['outlet', 'Outlet', 'On', true, false],
|
|
22
|
+
['door', 'Door', 'TargetPosition', 100, 0],
|
|
23
|
+
['window', 'Window', 'TargetPosition', 100, 0],
|
|
24
|
+
['windowcovering', 'WindowCovering', 'TargetPosition', 100, 0],
|
|
25
|
+
['garagedoor', 'GarageDoorOpener', 'TargetDoorState', 0, 1],
|
|
26
|
+
['lock', 'LockMechanism', 'LockTargetState', 0, 1],
|
|
27
|
+
['faucet', 'Faucet', 'Active', 1, 0],
|
|
28
|
+
['fan', 'Fanv2', 'Active', 1, 0],
|
|
29
|
+
['stateful', 'StatefulProgrammableSwitch', 'ProgrammableSwitchOutputState', 1, 0],
|
|
30
|
+
] as const
|
|
31
|
+
|
|
32
|
+
describe('bot HomeKit presentation', () => {
|
|
33
|
+
it('keeps the default Switch presentation', () => {
|
|
34
|
+
expect(bot({}).device.createHAPAccessory({}).services[0].type).toBe('Switch')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it.each(displays)('restores %s as a HAP %s with Press Mode', async (display, serviceType, controlName, active, inactive) => {
|
|
38
|
+
const { device, setDeviceState } = bot({ botDisplay: display, mode: 'press' })
|
|
39
|
+
const descriptor = device.createHAPAccessory({}).services[0]
|
|
40
|
+
expect(descriptor.type).toBe(serviceType)
|
|
41
|
+
const required = new (Service as any)[serviceType]().characteristics
|
|
42
|
+
const provided = new Set(Object.keys(descriptor.characteristics).map(name => (Characteristic as any)[name]?.UUID))
|
|
43
|
+
for (const characteristic of required) {
|
|
44
|
+
expect(provided.has(characteristic.UUID)).toBe(true)
|
|
45
|
+
}
|
|
46
|
+
const control = descriptor.characteristics[controlName]
|
|
47
|
+
await control.set(active)
|
|
48
|
+
await control.set(inactive)
|
|
49
|
+
expect(await control.get()).toBe(inactive)
|
|
50
|
+
expect(setDeviceState).toHaveBeenCalledExactlyOnceWith(id, {
|
|
51
|
+
command: 'press',
|
|
52
|
+
parameter: 'default',
|
|
53
|
+
commandType: 'command',
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it.each(displays)('registers %s and resets its HAP control after a press', async (display, serviceType, controlName, active, inactive) => {
|
|
58
|
+
const { device } = bot({ botDisplay: display, mode: 'press' })
|
|
59
|
+
const accessory = new Accessory('Entrance', uuid.generate(id))
|
|
60
|
+
accessory.addService(Service.Switch)
|
|
61
|
+
const api = { hap: { Service, Characteristic, uuid }, registerPlatformAccessories: vi.fn() }
|
|
62
|
+
const platform = Object.create(SwitchBotHAPPlatform.prototype) as any
|
|
63
|
+
platform.api = api
|
|
64
|
+
platform.log = log
|
|
65
|
+
platform.accessories = new Map([[uuid.generate(id), accessory]])
|
|
66
|
+
platform.hapPushTargets = new Map()
|
|
67
|
+
const created = { instance: device, createAccessory: () => device.createHAPAccessory(api) }
|
|
68
|
+
|
|
69
|
+
await platform.registerHAPAccessories([{ created, d: { id, name: 'Entrance' }, type: 'Bot' }])
|
|
70
|
+
|
|
71
|
+
const service = accessory.getService((Service as any)[serviceType])!
|
|
72
|
+
expect(service).toBeDefined()
|
|
73
|
+
if (serviceType !== 'Switch') {
|
|
74
|
+
expect(accessory.getService(Service.Switch)).toBeUndefined()
|
|
75
|
+
}
|
|
76
|
+
const characteristic = service.getCharacteristic((Characteristic as any)[controlName])
|
|
77
|
+
await characteristic.handleSetRequest(active)
|
|
78
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
79
|
+
expect(characteristic.value).toBe(inactive)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('uses turnOn and turnOff for physical Switch Mode', async () => {
|
|
83
|
+
const { device, setDeviceState } = bot({ botDisplay: 'door', mode: 'switch' })
|
|
84
|
+
const target = device.createHAPAccessory({}).services[0].characteristics.TargetPosition
|
|
85
|
+
await target.set(100)
|
|
86
|
+
expect(await target.get()).toBe(100)
|
|
87
|
+
await target.set(0)
|
|
88
|
+
expect(await target.get()).toBe(0)
|
|
89
|
+
expect(setDeviceState.mock.calls.map(([, body]) => body.command)).toEqual(['turnOn', 'turnOff'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reloads a Bot when its physical mode changes', async () => {
|
|
93
|
+
const platform = Object.create(SwitchBotHAPPlatform.prototype) as any
|
|
94
|
+
platform.config = { devices: [{ deviceId: id, configDeviceType: 'Bot', mode: 'switch' }] }
|
|
95
|
+
platform.lastConfigHash = platform.getConfigHash()
|
|
96
|
+
platform.config.devices[0].mode = 'press'
|
|
97
|
+
platform.log = log
|
|
98
|
+
platform.accessories = new Map()
|
|
99
|
+
platform.devices = []
|
|
100
|
+
platform.loadDevices = vi.fn(async () => {})
|
|
101
|
+
|
|
102
|
+
await platform.checkAndReloadDevices()
|
|
103
|
+
|
|
104
|
+
expect(platform.loadDevices).toHaveBeenCalledOnce()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('emits each Stateful Programmable Switch press as an event', async () => {
|
|
108
|
+
const { device, setDeviceState } = bot({ botDisplay: 'stateful', mode: 'press' })
|
|
109
|
+
const accessory = new Accessory('Entrance', uuid.generate(id))
|
|
110
|
+
const api = { hap: { Service, Characteristic, uuid }, registerPlatformAccessories: vi.fn() }
|
|
111
|
+
const platform = Object.create(SwitchBotHAPPlatform.prototype) as any
|
|
112
|
+
platform.api = api
|
|
113
|
+
platform.log = log
|
|
114
|
+
platform.accessories = new Map([[uuid.generate(id), accessory]])
|
|
115
|
+
platform.hapPushTargets = new Map()
|
|
116
|
+
const created = { instance: device, createAccessory: () => device.createHAPAccessory(api) }
|
|
117
|
+
await platform.registerHAPAccessories([{ created, d: { id, name: 'Entrance' }, type: 'Bot' }])
|
|
118
|
+
|
|
119
|
+
const service = accessory.getService(Service.StatefulProgrammableSwitch)!
|
|
120
|
+
const event = service.getCharacteristic(Characteristic.ProgrammableSwitchEvent)
|
|
121
|
+
const notify = vi.spyOn(event, 'sendEventNotification')
|
|
122
|
+
const output = service.getCharacteristic(Characteristic.ProgrammableSwitchOutputState)
|
|
123
|
+
await output.handleSetRequest(1)
|
|
124
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
125
|
+
await output.handleSetRequest(1)
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
127
|
+
await output.handleSetRequest(0)
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
129
|
+
|
|
130
|
+
expect(notify).toHaveBeenCalledTimes(2)
|
|
131
|
+
expect(notify).toHaveBeenCalledWith(0)
|
|
132
|
+
expect(setDeviceState).toHaveBeenCalledTimes(2)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('sends one press for Open and none for Close', async () => {
|
|
136
|
+
const { device, setDeviceState } = bot({ botDisplay: 'door' })
|
|
137
|
+
const chars = device.createHAPAccessory({}).services[0].characteristics
|
|
138
|
+
expect(await chars.TargetPosition.get()).toBe(0)
|
|
139
|
+
expect(await chars.CurrentPosition.get()).toBe(0)
|
|
140
|
+
expect(await chars.PositionState.get()).toBe(2)
|
|
141
|
+
await chars.TargetPosition.set(100)
|
|
142
|
+
await chars.TargetPosition.set(0)
|
|
143
|
+
expect(setDeviceState).toHaveBeenCalledExactlyOnceWith(id, {
|
|
144
|
+
command: 'press',
|
|
145
|
+
parameter: 'default',
|
|
146
|
+
commandType: 'command',
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('reports a failed press to HomeKit', async () => {
|
|
151
|
+
const { device } = bot({ type: 'door' }, { success: false, reason: 'offline' })
|
|
152
|
+
await expect(device.createHAPAccessory({}).services[0].characteristics.TargetPosition.set(100))
|
|
153
|
+
.rejects
|
|
154
|
+
.toThrow('offline')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('replaces a cached Switch and resets TargetPosition after HAP commits the write', async () => {
|
|
158
|
+
const { device, setDeviceState } = bot({ botDisplay: 'door' })
|
|
159
|
+
const accessory = new Accessory('Entrance', uuid.generate(id))
|
|
160
|
+
accessory.addService(Service.Switch)
|
|
161
|
+
const api = {
|
|
162
|
+
hap: { Service, Characteristic, uuid },
|
|
163
|
+
registerPlatformAccessories: vi.fn(),
|
|
164
|
+
}
|
|
165
|
+
const platform = Object.create(SwitchBotHAPPlatform.prototype) as any
|
|
166
|
+
platform.api = api
|
|
167
|
+
platform.log = log
|
|
168
|
+
platform.accessories = new Map([[uuid.generate(id), accessory]])
|
|
169
|
+
platform.hapPushTargets = new Map()
|
|
170
|
+
const created = { instance: device, createAccessory: () => device.createHAPAccessory(api) }
|
|
171
|
+
|
|
172
|
+
await platform.registerHAPAccessories([{ created, d: { id, name: 'Entrance' }, type: 'Bot' }])
|
|
173
|
+
|
|
174
|
+
expect(accessory.getService(Service.Switch)).toBeUndefined()
|
|
175
|
+
const door = accessory.getService(Service.Door)!
|
|
176
|
+
expect(door.getCharacteristic(Characteristic.CurrentPosition)).toBeDefined()
|
|
177
|
+
expect(door.getCharacteristic(Characteristic.PositionState)).toBeDefined()
|
|
178
|
+
const target = door.getCharacteristic(Characteristic.TargetPosition)
|
|
179
|
+
await target.handleSetRequest(100)
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
181
|
+
expect(target.value).toBe(0)
|
|
182
|
+
expect(setDeviceState).toHaveBeenCalledTimes(1)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* Copyright(C) 2021-2026, donavanbecker (https://github.com/donavanbecker). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* meter-co2.spec.ts: the Meter Pro (CO2) exposes a CarbonDioxideSensor service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WoSensorTHProCO2 } from 'node-switchbot'
|
|
7
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import { createDevice } from '../../src/deviceFactory.js'
|
|
10
|
+
import { MeterDevice } from '../../src/devices/genericDevice.js'
|
|
11
|
+
|
|
12
|
+
const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), success: vi.fn() }
|
|
13
|
+
|
|
14
|
+
function meter(deviceType: string | undefined, status: Record<string, unknown>, cfg: Record<string, unknown> = {}) {
|
|
15
|
+
const device = new MeterDevice({ id: 'B0E9FED044E3', type: 'meter', deviceType, name: 'Meter', log } as any, { log, ...cfg } as any)
|
|
16
|
+
vi.spyOn(device, 'getState').mockResolvedValue(status)
|
|
17
|
+
return device
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function serviceTypes(device: MeterDevice) {
|
|
21
|
+
return device.createHAPAccessory(null).services.map((s: any) => s.type)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function read(device: MeterDevice, type: string, characteristic: string) {
|
|
25
|
+
const service = device.createHAPAccessory(null).services.find((s: any) => s.type === type)
|
|
26
|
+
return service.characteristics[characteristic].get()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const READING = { temperature: 22.7, humidity: 55, co2: 483 }
|
|
30
|
+
|
|
31
|
+
describe('meterDevice CO2 service', () => {
|
|
32
|
+
it.each(['MeterPro(CO2)', 'Meter Pro (CO2)'])('is added for %s', (deviceType) => {
|
|
33
|
+
expect(serviceTypes(meter(deviceType, READING))).toEqual(['TemperatureSensor', 'HumiditySensor', 'CarbonDioxideSensor'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it.each(['Meter', 'Meter Plus', 'MeterPro', 'Outdoor Meter', undefined])('is not added for %s', (deviceType) => {
|
|
37
|
+
expect(serviceTypes(meter(deviceType, READING))).toEqual(['TemperatureSensor', 'HumiditySensor'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('reports the CO2 level in ppm', async () => {
|
|
41
|
+
await expect(read(meter('MeterPro(CO2)', READING), 'CarbonDioxideSensor', 'CarbonDioxideLevel')).resolves.toBe(483)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('leaves temperature and humidity as they were', async () => {
|
|
45
|
+
const device = meter('MeterPro(CO2)', READING)
|
|
46
|
+
|
|
47
|
+
await expect(read(device, 'TemperatureSensor', 'CurrentTemperature')).resolves.toBe(22.7)
|
|
48
|
+
await expect(read(device, 'HumiditySensor', 'CurrentRelativeHumidity')).resolves.toBe(55)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('meterDevice CO2 detected threshold', () => {
|
|
53
|
+
const detected = (co2: number, cfg: Record<string, unknown> = {}) =>
|
|
54
|
+
read(meter('MeterPro(CO2)', { ...READING, co2 }, cfg), 'CarbonDioxideSensor', 'CarbonDioxideDetected')
|
|
55
|
+
|
|
56
|
+
it('defaults to 1000 ppm', async () => {
|
|
57
|
+
await expect(detected(999)).resolves.toBe(0)
|
|
58
|
+
await expect(detected(1000)).resolves.toBe(1)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('uses co2AbnormalThreshold when set', async () => {
|
|
62
|
+
await expect(detected(799, { co2AbnormalThreshold: 800 })).resolves.toBe(0)
|
|
63
|
+
await expect(detected(800, { co2AbnormalThreshold: 800 })).resolves.toBe(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.each([['zero', 0], ['negative', -1], ['text', 'high'], ['missing', undefined]])(
|
|
67
|
+
'falls back to 1000 when the setting is %s',
|
|
68
|
+
async (_label, value) => {
|
|
69
|
+
await expect(detected(999, { co2AbnormalThreshold: value })).resolves.toBe(0)
|
|
70
|
+
await expect(detected(1000, { co2AbnormalThreshold: value })).resolves.toBe(1)
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// End to end: the plugin's createDevice() with the real node-switchbot class,
|
|
76
|
+
// fed the status body a real Meter Pro (CO2) returned from the OpenAPI.
|
|
77
|
+
describe('a Meter Pro (CO2) end to end', () => {
|
|
78
|
+
it('reports CO2 to HomeKit through node-switchbot', async () => {
|
|
79
|
+
const apiBody = { version: 'V1.8', temperature: 22.7, battery: 100, humidity: 55, CO2: 483, deviceId: 'B0E9FED044E3', deviceType: 'MeterPro(CO2)', hubDeviceId: '000000000000' }
|
|
80
|
+
const switchBotDevice = new WoSensorTHProCO2(
|
|
81
|
+
{ id: 'B0E9FED044E3', name: 'Meter Pro CO2 Monitor', deviceType: 'MeterPro(CO2)', connectionTypes: ['api'] } as any,
|
|
82
|
+
{ apiClient: { getStatus: async () => apiBody } } as any,
|
|
83
|
+
)
|
|
84
|
+
const client = { init: async () => {}, getDevice: async () => switchBotDevice }
|
|
85
|
+
|
|
86
|
+
const created: any = await createDevice(
|
|
87
|
+
{ id: 'B0E9FED044E3', type: 'meter', deviceType: 'MeterPro(CO2)', name: 'Meter Pro CO2 Monitor', log } as any,
|
|
88
|
+
{ log, _client: client } as any,
|
|
89
|
+
false,
|
|
90
|
+
)
|
|
91
|
+
const service = created.createAccessory(null).services.find((s: any) => s.type === 'CarbonDioxideSensor')
|
|
92
|
+
|
|
93
|
+
await expect(service.characteristics.CarbonDioxideLevel.get()).resolves.toBe(483)
|
|
94
|
+
await expect(service.characteristics.CarbonDioxideDetected.get()).resolves.toBe(0)
|
|
95
|
+
})
|
|
96
|
+
})
|