@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.
Files changed (44) hide show
  1. package/.github/scripts/compute-version-override.cjs +51 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +2 -0
  4. package/config.schema.json +40 -1
  5. package/dist/SwitchBotHAPPlatform.d.ts.map +1 -1
  6. package/dist/SwitchBotHAPPlatform.js +35 -18
  7. package/dist/SwitchBotHAPPlatform.js.map +1 -1
  8. package/dist/device-types.d.ts.map +1 -1
  9. package/dist/device-types.js +10 -0
  10. package/dist/device-types.js.map +1 -1
  11. package/dist/deviceFactory.d.ts.map +1 -1
  12. package/dist/deviceFactory.js +5 -1
  13. package/dist/deviceFactory.js.map +1 -1
  14. package/dist/devices/genericDevice.d.ts +10 -17
  15. package/dist/devices/genericDevice.d.ts.map +1 -1
  16. package/dist/devices/genericDevice.js +125 -20
  17. package/dist/devices/genericDevice.js.map +1 -1
  18. package/dist/homebridge-ui/device-types.js +10 -0
  19. package/dist/homebridge-ui/device-types.js.map +1 -1
  20. package/dist/homebridge-ui/public/index.html +5 -0
  21. package/dist/homebridge-ui/public/js/advanced-settings.d.ts.map +1 -1
  22. package/dist/homebridge-ui/public/js/advanced-settings.js +2 -0
  23. package/dist/homebridge-ui/public/js/advanced-settings.js.map +1 -1
  24. package/dist/homebridge-ui/public/js/advanced-settings.ts +2 -0
  25. package/dist/homebridge-ui/public/js/app.js +20 -5
  26. package/dist/homebridge-ui/public/js/app.js.map +2 -2
  27. package/docs/assets/highlight.css +1 -1
  28. package/docs/assets/main.js +1 -1
  29. package/docs/assets/style.css +18 -3
  30. package/docs/hierarchy.html +26 -1
  31. package/docs/index.html +68 -2
  32. package/docs/modules.html +33 -1
  33. package/docs/variables/default.html +46 -3
  34. package/package.json +17 -16
  35. package/src/SwitchBotHAPPlatform.ts +35 -17
  36. package/src/device-types.ts +11 -0
  37. package/src/deviceFactory.ts +5 -1
  38. package/src/devices/genericDevice.ts +136 -20
  39. package/src/homebridge-ui/public/index.html +5 -0
  40. package/src/homebridge-ui/public/js/advanced-settings.ts +2 -0
  41. package/test/device/bot-hap.spec.ts +184 -0
  42. package/test/device/meter-co2.spec.ts +96 -0
  43. package/test/device/model-identities.spec.ts +45 -0
  44. 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
- const deviceOpts: any = { id: d.id, type, name: d.name, encryptionKey: d.encryptionKey, keyId: d.keyId, log: this.log }
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 charNames = Object.keys(s.characteristics || {})
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 (!charNames.includes(existingChar.displayName)) {
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
- for (const refreshCharName of refreshAfterSet) {
320
- const refreshGetterSetter: any = (s.characteristics || {})[refreshCharName]
321
- if (!refreshGetterSetter || typeof refreshGetterSetter.get !== 'function') {
322
- continue
323
- }
324
- const RefreshCharacteristic = (hap.Characteristic as any)[refreshCharName]
325
- if (!RefreshCharacteristic) {
326
- continue
327
- }
328
- try {
329
- service.getCharacteristic(RefreshCharacteristic).updateValue(await refreshGetterSetter.get())
330
- } catch (e) {
331
- this.log.debug?.(`Failed to refresh ${refreshCharName} after setting ${charName}`, e)
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
 
@@ -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
@@ -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
- const key = DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey
144
+ // Normalised types are canonical display names; class map keys are lowercase
145
+ const key = (DEVICE_TYPE_NORMALIZATION_MAP[rawKey] ?? rawKey).toLowerCase()
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
- return {
1370
- services: [
1371
- {
1372
- type: 'TemperatureSensor',
1373
- characteristics: {
1374
- CurrentTemperature: {
1375
- get: async () => {
1376
- const s = await this.getState()
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
- type: 'HumiditySensor',
1384
- characteristics: {
1385
- CurrentRelativeHumidity: {
1386
- get: async () => {
1387
- const s = await this.getState()
1388
- return typeof s.humidity === 'number' ? s.humidity : 0
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
+ })