@switchbot/homebridge-switchbot 5.1.0 → 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/.github/scripts/compute-version-override.cjs +51 -0
- package/CHANGELOG.md +31 -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/device-types.d.ts.map +1 -1
- package/dist/device-types.js +10 -0
- package/dist/device-types.js.map +1 -1
- package/dist/deviceFactory.d.ts.map +1 -1
- package/dist/deviceFactory.js +5 -1
- package/dist/deviceFactory.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/device-types.js +10 -0
- package/dist/homebridge-ui/device-types.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/dist/homebridge-ui/public/js/app.js +20 -5
- package/dist/homebridge-ui/public/js/app.js.map +2 -2
- package/docs/assets/highlight.css +1 -1
- package/docs/assets/main.js +1 -1
- package/docs/assets/style.css +18 -3
- package/docs/hierarchy.html +26 -1
- package/docs/index.html +68 -2
- package/docs/modules.html +33 -1
- package/docs/variables/default.html +46 -3
- package/package.json +17 -16
- package/src/SwitchBotHAPPlatform.ts +35 -17
- package/src/device-types.ts +11 -0
- package/src/deviceFactory.ts +5 -1
- 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
- package/test/device/model-identities.spec.ts +45 -0
- package/src/device-types.js +0 -246
|
@@ -155,7 +155,9 @@ export class SwitchBotHAPPlatform {
|
|
|
155
155
|
_raw: raw,
|
|
156
156
|
}
|
|
157
157
|
const type: string = normalizeTypeForMatter(d.type)
|
|
158
|
-
|
|
158
|
+
// `type` is normalised for class selection, so MeterPro(CO2) and Meter both
|
|
159
|
+
// become 'meter'. Pass the configured type too, so a class can tell them apart.
|
|
160
|
+
const deviceOpts: any = { id: d.id, type, deviceType: d.type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId, log: this.log }
|
|
159
161
|
this.log.debug(`[HAP/Debug] Device options for ${d.name ?? d.id}:`, JSON.stringify(deviceOpts, null, 2))
|
|
160
162
|
try {
|
|
161
163
|
const created = await createDevice(deviceOpts, this.config, false)
|
|
@@ -285,9 +287,13 @@ export class SwitchBotHAPPlatform {
|
|
|
285
287
|
continue
|
|
286
288
|
}
|
|
287
289
|
const service = accessory.getService(Service) || accessory.addService(Service)
|
|
288
|
-
const
|
|
290
|
+
const characteristicUUIDs = new Set(
|
|
291
|
+
Object.keys(s.characteristics || {})
|
|
292
|
+
.map(name => hap.Characteristic[name]?.UUID)
|
|
293
|
+
.filter(Boolean),
|
|
294
|
+
)
|
|
289
295
|
for (const existingChar of service.characteristics.slice()) {
|
|
290
|
-
if (!
|
|
296
|
+
if (!characteristicUUIDs.has(existingChar.UUID)) {
|
|
291
297
|
service.removeCharacteristic(existingChar)
|
|
292
298
|
}
|
|
293
299
|
}
|
|
@@ -316,20 +322,30 @@ export class SwitchBotHAPPlatform {
|
|
|
316
322
|
await getterSetter.set(value)
|
|
317
323
|
|
|
318
324
|
const refreshAfterSet = Array.isArray(getterSetter.refreshAfterSet) ? getterSetter.refreshAfterSet : []
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
325
|
+
// HAP stores the requested value after onSet resolves. Refresh on the next tick
|
|
326
|
+
// so a momentary control can return to its resting value.
|
|
327
|
+
if (refreshAfterSet.length > 0 || typeof getterSetter.afterSet === 'function') {
|
|
328
|
+
setTimeout(async () => {
|
|
329
|
+
for (const refreshCharName of refreshAfterSet) {
|
|
330
|
+
const refreshGetterSetter: any = (s.characteristics || {})[refreshCharName]
|
|
331
|
+
const RefreshCharacteristic = (hap.Characteristic as any)[refreshCharName]
|
|
332
|
+
if (!RefreshCharacteristic || typeof refreshGetterSetter?.get !== 'function') {
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
service.getCharacteristic(RefreshCharacteristic).updateValue(await refreshGetterSetter.get())
|
|
337
|
+
} catch (e) {
|
|
338
|
+
this.log.debug?.(`Failed to refresh ${refreshCharName} after setting ${charName}`, e)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (typeof getterSetter.afterSet === 'function') {
|
|
342
|
+
try {
|
|
343
|
+
await getterSetter.afterSet(value, service, hap)
|
|
344
|
+
} catch (e) {
|
|
345
|
+
this.log.debug?.(`Failed afterSet for ${charName}`, e)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}, 0)
|
|
333
349
|
}
|
|
334
350
|
})
|
|
335
351
|
}
|
|
@@ -385,6 +401,8 @@ export class SwitchBotHAPPlatform {
|
|
|
385
401
|
id: d.deviceId ?? d.id,
|
|
386
402
|
type: d.configDeviceType ?? d.type,
|
|
387
403
|
name: d.configDeviceName ?? d.name,
|
|
404
|
+
botDisplay: d.botDisplay ?? d.type,
|
|
405
|
+
mode: d.mode,
|
|
388
406
|
})))
|
|
389
407
|
}
|
|
390
408
|
|
package/src/device-types.ts
CHANGED
|
@@ -191,6 +191,17 @@ export const DEVICE_TYPE_NORMALIZATION_MAP: Record<string, string> = {
|
|
|
191
191
|
|
|
192
192
|
// Additional normalization for new/unknown types from logs
|
|
193
193
|
'woplugus': 'Plug Mini (US)',
|
|
194
|
+
|
|
195
|
+
// Product-specific identities reported by node-switchbot 5 (pySwitchbot 3.0.0 parity)
|
|
196
|
+
'woplugjp': 'Plug Mini (JP)',
|
|
197
|
+
'wocurtain3': 'Curtain3',
|
|
198
|
+
'wometerplus': 'Meter Plus',
|
|
199
|
+
'wothpc': 'Meter Pro (CO2)',
|
|
200
|
+
'woceilingpro': 'Ceiling Light Pro',
|
|
201
|
+
'wolinkmini': 'Hub Mini',
|
|
202
|
+
'woiosensorth': 'WoIOSensor',
|
|
203
|
+
'outdoor meter': 'WoIOSensor',
|
|
204
|
+
'indoor/outdoor thermo-hygrometer': 'WoIOSensor',
|
|
194
205
|
// Removed duplicate keys below, only last occurrence kept
|
|
195
206
|
// 'plug mini us': 'plug mini (us)', // duplicate, removed
|
|
196
207
|
// 'plug us': 'plug mini (us)', // duplicate, removed
|
package/src/deviceFactory.ts
CHANGED
|
@@ -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,
|
|
@@ -138,7 +141,8 @@ const DEVICE_CLASS_MAP: Record<string, any> = {
|
|
|
138
141
|
|
|
139
142
|
function classForType(type: string) {
|
|
140
143
|
const rawKey = (type || '').toLowerCase()
|
|
141
|
-
|
|
144
|
+
// Normalised types are canonical display names; class map keys are lowercase
|
|
145
|
+
const key = (DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey).toLowerCase()
|
|
142
146
|
return DEVICE_CLASS_MAP[key] ?? GenericDevice
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { normalizeDeviceType } from '../../src/device-types'
|
|
4
|
+
import { createDevice } from '../../src/deviceFactory'
|
|
5
|
+
|
|
6
|
+
// node-switchbot 5 reports product-specific identities (pySwitchbot 3.0.0 parity).
|
|
7
|
+
// Every identity must reach the same accessory class as its canonical display name.
|
|
8
|
+
describe('product-specific model identities', () => {
|
|
9
|
+
const mockLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }
|
|
10
|
+
const dummyConfig = { logger: mockLogger, log: mockLogger }
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
['WoPlugUS', 'Plug Mini (US)'],
|
|
14
|
+
['WoPlugJP', 'Plug Mini (JP)'],
|
|
15
|
+
['WoCurtain3', 'Curtain3'],
|
|
16
|
+
['WoMeterPlus', 'Meter Plus'],
|
|
17
|
+
['WoTHPc', 'Meter Pro (CO2)'],
|
|
18
|
+
['WoCeilingPro', 'Ceiling Light Pro'],
|
|
19
|
+
['WoLinkMini', 'Hub Mini'],
|
|
20
|
+
['WoIOSensorTH', 'WoIOSensor'],
|
|
21
|
+
['Outdoor Meter', 'WoIOSensor'],
|
|
22
|
+
['Indoor/Outdoor Thermo-Hygrometer', 'WoIOSensor'],
|
|
23
|
+
])('normalizes %s to %s', (input, expected) => {
|
|
24
|
+
expect(normalizeDeviceType(input)).toBe(expected)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
['WoPlugUS', 'PlugMiniDevice'],
|
|
29
|
+
['Plug Mini (US)', 'PlugMiniDevice'],
|
|
30
|
+
['WoPlugJP', 'PlugMiniDevice'],
|
|
31
|
+
['Plug Mini (JP)', 'PlugMiniDevice'],
|
|
32
|
+
['WoCurtain3', 'Curtain3Device'],
|
|
33
|
+
['Curtain3', 'Curtain3Device'],
|
|
34
|
+
['WoMeterPlus', 'MeterDevice'],
|
|
35
|
+
['Meter Plus', 'MeterDevice'],
|
|
36
|
+
['WoTHPc', 'MeterDevice'],
|
|
37
|
+
['Meter Pro (CO2)', 'MeterDevice'],
|
|
38
|
+
['Meter Pro', 'MeterDevice'],
|
|
39
|
+
['WoCeilingPro', 'LightDevice'],
|
|
40
|
+
['Ceiling Light Pro', 'LightDevice'],
|
|
41
|
+
])('creates the right accessory for %s', async (type, className) => {
|
|
42
|
+
const result = await createDevice({ id: `id-${type}`, type } as any, dummyConfig as any, false)
|
|
43
|
+
expect(result.instance.constructor.name).toBe(className)
|
|
44
|
+
})
|
|
45
|
+
})
|