@homebridge-plugins/homebridge-govee 11.32.2-beta.9 → 11.32.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 (76) hide show
  1. package/CHANGELOG.md +32 -3
  2. package/README.md +1 -1
  3. package/lib/connection/ble.js +156 -19
  4. package/lib/connection/openapi.js +18 -0
  5. package/lib/device/appliance-basic.js +116 -24
  6. package/lib/device/base.js +80 -0
  7. package/lib/device/cooler-single.js +7 -22
  8. package/lib/device/dehumidifier-H7150.js +8 -26
  9. package/lib/device/diffuser-H7161.js +5 -18
  10. package/lib/device/fan-H1370.js +13 -46
  11. package/lib/device/fan-H7100.js +36 -33
  12. package/lib/device/fan-H7105.js +15 -53
  13. package/lib/device/fan-H7107.js +15 -53
  14. package/lib/device/heater-single.js +7 -22
  15. package/lib/device/heater1a.js +21 -42
  16. package/lib/device/heater1b.js +17 -61
  17. package/lib/device/heater2.js +23 -81
  18. package/lib/device/humidifier-H7140.js +9 -32
  19. package/lib/device/humidifier-H7141.js +7 -25
  20. package/lib/device/humidifier-H7142.js +13 -46
  21. package/lib/device/humidifier-H7143.js +7 -25
  22. package/lib/device/humidifier-H7160.js +13 -46
  23. package/lib/device/ice-maker-H7172.js +18 -29
  24. package/lib/device/index.js +22 -29
  25. package/lib/device/kettle.js +5 -16
  26. package/lib/device/light-switch.js +5 -18
  27. package/lib/device/light.js +14 -41
  28. package/lib/device/outlet-single.js +8 -18
  29. package/lib/device/purifier-H7120.js +20 -68
  30. package/lib/device/purifier-H7122.js +14 -45
  31. package/lib/device/purifier-H7123.js +15 -46
  32. package/lib/device/purifier-H7126.js +12 -38
  33. package/lib/device/purifier-single.js +7 -19
  34. package/lib/device/sensor-basic.js +25 -0
  35. package/lib/device/sensor-co2.js +5 -11
  36. package/lib/device/sensor-leak.js +4 -9
  37. package/lib/device/sensor-monitor.js +3 -10
  38. package/lib/device/sensor-presence.js +3 -10
  39. package/lib/device/sensor-probe.js +52 -0
  40. package/lib/device/sensor-thermo-switch.js +4 -9
  41. package/lib/device/sensor-thermo.js +4 -9
  42. package/lib/device/switch-double.js +39 -32
  43. package/lib/device/switch-single.js +6 -19
  44. package/lib/device/switch-triple.js +41 -35
  45. package/lib/device/tap-single.js +7 -19
  46. package/lib/device/template.js +3 -10
  47. package/lib/device/tv-single.js +6 -18
  48. package/lib/device/valve-single.js +7 -19
  49. package/lib/platform.js +107 -813
  50. package/lib/utils/ble-crypto.js +130 -0
  51. package/lib/utils/ble-protocol.js +8 -0
  52. package/lib/utils/capabilities.js +221 -0
  53. package/lib/utils/command-builder.js +2 -0
  54. package/lib/utils/config-parser.js +280 -0
  55. package/lib/utils/constants.js +28 -6
  56. package/lib/utils/device-capabilities.js +34 -1
  57. package/lib/utils/device-context.js +179 -0
  58. package/lib/utils/device-merge.js +251 -0
  59. package/lib/utils/dispatch.js +161 -0
  60. package/lib/utils/functions.js +9 -0
  61. package/package.json +1 -1
  62. package/lib/device/dehumidifier-H7151.js +0 -195
  63. package/lib/device/dehumidifier-H7152.js +0 -186
  64. package/lib/device/fan-H1310.js +0 -290
  65. package/lib/device/fan-H7101.js +0 -346
  66. package/lib/device/fan-H7111.js +0 -351
  67. package/lib/device/ice-maker-H8121.js +0 -228
  68. package/lib/device/ice-maker-H8122.js +0 -228
  69. package/lib/device/outlet-double.js +0 -188
  70. package/lib/device/outlet-triple.js +0 -160
  71. package/lib/device/purifier-H7121.js +0 -412
  72. package/lib/device/purifier-H7124.js +0 -510
  73. package/lib/device/sensor-button.js +0 -27
  74. package/lib/device/sensor-contact.js +0 -27
  75. package/lib/device/sensor-thermo4.js +0 -66
  76. package/lib/utils/appliance-capabilities.js +0 -45
package/CHANGELOG.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
- ## v11.32.2 (Pending Release)
5
+ ## v11.32.2 (2026-08-03)
6
6
 
7
7
  ### Changed
8
8
 
9
+ - fix: stop shipping the test files to npm, since they are only needed in the repository
10
+ - fix: BLE device control on macOS by resolving peripherals via advertised name (#1336) (@Perafan18)
9
11
  - fix: implement the H7107's oscillation toggle, which threw a not-implemented error when tapped (#1334)
10
12
  - fix: also send the H7107's oscillation command as the multiSync frame the fan itself uses, since the first shape only turned it off sometimes (#1334)
11
13
  - fix: send the H7107's oscillation-off command without the swing-range bytes, matching the zero-tailed frame the fan echoes back (#1334)
@@ -20,8 +22,35 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
20
22
  - feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
21
23
  - feat: give every appliance type a starting handler, so a new model works for on and off and asks for the rest
22
24
  - refactor: point models that behave identically at one shared handler instead of copies of the same file
23
- - fix: stop shipping the test files to npm, since they are only needed in the repository
24
- - docs: keep the structure notes outside the repository, since they are working notes rather than plugin documentation
25
+ - fix: refuse to send a bluetooth command too long to fit in a packet, rather than sending a longer one the device quietly ignores
26
+ - test: record what every supported model turns into, so a change can be shown to have altered nothing
27
+ - feat: work out what an appliance can do from what the device itself reports, so a model nobody has written down still gets the right controls
28
+ - feat: offer oscillation and the child lock on any appliance that reports having them, driven through govee's api rather than needing that model's command codes
29
+ - refactor: point three more models at a shared handler instead of a near-copy of the same file, keeping each one's behaviour exactly as it was
30
+ - fix: give the H7124 the air quality handling the H7123 already had, so a poor reading is named properly and stops being logged over and over (#1261)
31
+ - refactor: point the H7151 and H7111 at the handlers they were byte-for-byte copies of
32
+ - refactor: build the ice maker size codes from the model rather than writing both orderings out by hand
33
+ - refactor: build the switch and outlet tiles of a multi-socket device from one handler instead of two copies of it
34
+ - fix: move the H7152 dehumidifier's speed slider when it reports a new speed, which until now only the H7150 and H7151 did
35
+ - refactor: point the H7101 and H7111 fans at the H7100 handler they matched command for command
36
+ - feat: give a diffuser model that has not been worked out yet the same starting handler the other appliances have, so it works for on and off
37
+ - refactor: choose a device handler from a table, so adding a device type no longer means editing the platform
38
+ - refactor: move the settings parsing into its own file, away from the code that talks to devices
39
+ - refactor: move the work of describing a device to its handler into its own file
40
+ - refactor: move the rules for combining govee's device lists, and for letting go of a device that has gone, into their own file with tests
41
+ - test: check what the plugin sends over aws, the local network and the public api, none of which was covered before
42
+ - feat: say when govee has not listed a capability the plugin is about to use, since the request then succeeds and the device does nothing
43
+ - docs: note that the local network api accepts the raw command form, which is how scenes work without the cloud
44
+ - refactor: give every device one shared starting point, so the way a failed command is reported and undone is decided in one place rather than a hundred
45
+ - feat: recognise eleven more meat thermometers, which report what they send so their readings can be worked out, and stop the one already known printing its whole update as a warning
46
+ - feat: add the H5161 power strip and give the H7184 kitchen composter an on and off switch
47
+ - feat: recognise three more sensors, which report what they send, and say plainly in the log why a sensor has no tile yet
48
+ - feat: add ble encryption for the H3001 solar string lights, which silently ignore unencrypted frames (#1328) (@dominick-han)
49
+ - fix: mention an unrecognised bluetooth broadcast once, and only for one of your own sensors, since a scan also hears your lights and the neighbours
50
+ - fix: say "device(s)" in the connection summary, since the count is often one
51
+ - fix: stop the macOS bluetooth scan leaving its timer running once the device has been found
52
+ - docs: say that macOS bluetooth support is new and ask for reports, rather than stating it works
53
+ - chore(deps): dependency updates
25
54
 
26
55
  ## v11.32.1 (2026-08-02)
27
56
 
package/README.md CHANGED
@@ -29,7 +29,7 @@ Homebridge plugin to integrate Govee devices into HomeKit
29
29
  - To use this plugin, you will need to already have:
30
30
  - [Node](https://nodejs.org): latest version of `v22`, `v24` or `v26` - any other major version is not supported.
31
31
  - [Homebridge](https://homebridge.io): `v1.6` or above - refer to link for more information and installation instructions.
32
- - For bluetooth connectivity, it may be necessary to install extra packages on your system, see [Bluetooth Control](https://github.com/homebridge-plugins/homebridge-govee/wiki/Bluetooth-Control). Bluetooth works best when using a Raspberry Pi, not been tested on Windows, and Mac devices are unsupported.
32
+ - For bluetooth connectivity, it may be necessary to install extra packages on your system, see [Bluetooth Control](https://github.com/homebridge-plugins/homebridge-govee/wiki/Bluetooth-Control). Bluetooth works best on a Raspberry Pi, and has not been tested on Windows. macOS is newly supported from `v11.32.2` and has only been tried on a handful of devices so far - you will need to give the `node` binary running Homebridge permission under System Settings → Privacy & Security → Bluetooth, and please do report anything that does not work.
33
33
 
34
34
  ### Setup
35
35
 
@@ -3,8 +3,10 @@ import process from 'node:process'
3
3
 
4
4
  import btClient from '@stoprocent/noble'
5
5
 
6
+ import { buildHandshake, isHandshakeFrame, parseHandshakeResponse, V2Session } from '../utils/ble-crypto.js'
6
7
  import { BLE_STAGE_TIMEOUTS, UUID } from '../utils/ble-protocol.js'
7
8
  import { decodeAny } from '../utils/decode.js'
9
+ import { getDeviceCapabilities } from '../utils/device-capabilities.js'
8
10
  import { base64ToHex, generateCodeFromHexValues, hexToTwoItems, sleep } from '../utils/functions.js'
9
11
  import platformLang from '../utils/lang-en.js'
10
12
  import { isFirstReport } from '../utils/report-unknown.js'
@@ -20,6 +22,7 @@ const H5101_UUID = '0001'
20
22
  const POWER_ON_TIMEOUT = BLE_STAGE_TIMEOUTS.powerOn
21
23
  const CONNECTION_TIMEOUT = BLE_STAGE_TIMEOUTS.connect
22
24
  const WRITE_TIMEOUT = BLE_STAGE_TIMEOUTS.write
25
+ const HANDSHAKE_TIMEOUT = BLE_STAGE_TIMEOUTS.handshake
23
26
  const SETTLE_DELAY = BLE_STAGE_TIMEOUTS.settle
24
27
 
25
28
  /*
@@ -121,30 +124,39 @@ export default class BLEConnection {
121
124
  try {
122
125
  const { uuid, address, rssi, advertisement } = peripheral
123
126
 
124
- // Skip if not a valid Govee sensor. Sensors whose broadcast the plugin
125
- // cannot decode yet are logged rather than dropped in silence, since the
126
- // hex is the one thing needed to add a model, and there is no other way
127
- // for an owner to get hold of it.
127
+ // A broadcast this plugin cannot decode is worth reporting rather than
128
+ // dropping in silence, since the hex is the one thing needed to add a
129
+ // model and an owner has no other way to get hold of it.
128
130
  if (!isValidPeripheral(peripheral)) {
129
131
  const unknownName = advertisement?.localName
130
132
  if (unknownName && unknownName.toLowerCase().startsWith('govee')) {
131
133
  const raw = advertisement.manufacturerData
132
134
  ? advertisement.manufacturerData.toString('hex')
133
135
  : 'no manufacturer data'
134
- const line = `[BLE] unrecognised govee sensor - please include this line if you report it: ${JSON.stringify({
135
- name: unknownName,
136
- address,
137
- source: 'BLE',
138
- kind: 'broadcast',
139
- raw,
140
- })}`
141
-
142
- // Say it once so it is actually noticed, then stay quiet about this
143
- // sensor for the rest of the session - it rebroadcasts constantly.
136
+
137
+ // A device rebroadcasts constantly, so say this once and then stay
138
+ // quiet about it for the rest of the session. This has to come first:
139
+ // gating it on the warning below would leave every other broadcast
140
+ // ungoverned, and a debug log would fill with the same line.
144
141
  if (isFirstReport(`ble|${address}|${raw}`)) {
145
- this.log.warn(line)
146
- } else {
147
- this.log.debug(line)
142
+ const line = `[BLE] unrecognised broadcast from a govee device - please include this line if you report it: ${JSON.stringify({
143
+ name: unknownName,
144
+ address,
145
+ source: 'BLE',
146
+ kind: 'broadcast',
147
+ raw,
148
+ })}`
149
+
150
+ // Only one of this account's own sensors is worth putting in front
151
+ // of someone: that is the case where the plugin should be reading
152
+ // the broadcast and cannot. A light of theirs broadcasts just as
153
+ // often but is driven over aws/lan/ble, and an address that is not
154
+ // on the account at all may belong to a neighbour.
155
+ if (this.platform.hasBleSensor(address)) {
156
+ this.log.warn(line)
157
+ } else {
158
+ this.log.debug(line)
159
+ }
148
160
  }
149
161
  }
150
162
  return
@@ -265,8 +277,19 @@ export default class BLEConnection {
265
277
  const finalBuffer = this.prepareCommandBuffer(params)
266
278
  accessory.logDebug(`sending command: ${finalBuffer.toString('hex')}`)
267
279
 
280
+ // Encrypted models refuse the plain frame. Because writes are
281
+ // unacknowledged, they refuse it silently, so the handshake has to happen
282
+ // here rather than being discovered by a failed write. It is per
283
+ // connection: the frame counter resets with the link.
284
+ let payload = finalBuffer
285
+ if (getDeviceCapabilities(accessory.context.gvModel).bleEncryption === 'v2') {
286
+ const session = await this.negotiateSession(accessory, characteristic, characteristics)
287
+ payload = session.seal(finalBuffer)
288
+ accessory.logDebug(`encrypted as: ${payload.toString('hex')}`)
289
+ }
290
+
268
291
  // Write without response then allow time for the BLE controller to transmit before disconnecting
269
- await this.writeWithTimeout(characteristic, finalBuffer, WRITE_TIMEOUT)
292
+ await this.writeWithTimeout(characteristic, payload, WRITE_TIMEOUT)
270
293
  accessory.logDebug('command sent successfully')
271
294
  await sleep(SETTLE_DELAY)
272
295
  } catch (err) {
@@ -299,11 +322,77 @@ export default class BLEConnection {
299
322
  }
300
323
  }
301
324
 
325
+ /**
326
+ * Exchange a session key with an encrypted model.
327
+ *
328
+ * Unlike every other path in this client we need a reply, which arrives as a
329
+ * notification on UUID.NOTIFY. Note that we deliberately do NOT subscribe:
330
+ * these lights push notifications without the client ever writing the client
331
+ * characteristic configuration descriptor.
332
+ * Calling subscribeAsync() here makes the descriptor write hang and the light
333
+ * then drops the connection, so listening is both sufficient and required.
334
+ *
335
+ * The reply is authenticated - parseHandshakeResponse throws if the GCM tag
336
+ * does not verify - which turns a wrong key into a real error rather than a
337
+ * light that silently ignores every command.
338
+ *
339
+ * Returns a V2Session that wraps outgoing frames.
340
+ */
341
+ async negotiateSession(accessory, writeCharacteristic, characteristics) {
342
+ const notifyCharacteristic = Object.values(characteristics).find(
343
+ char => char.uuid.replace(HYPHEN_REGEX, '') === UUID.NOTIFY,
344
+ )
345
+ if (!notifyCharacteristic) {
346
+ throw new Error('Notify characteristic not found, cannot negotiate encryption')
347
+ }
348
+
349
+ let resolveReply
350
+ let rejectReply
351
+ const reply = new Promise((resolve, reject) => {
352
+ resolveReply = resolve
353
+ rejectReply = reject
354
+ })
355
+ // Attach a handler immediately: if the write below throws, the timeout would
356
+ // otherwise reject an unawaited promise and crash the process.
357
+ reply.catch(() => {})
358
+
359
+ const onData = (data) => {
360
+ // The light also pushes state frames here; take only the handshake.
361
+ if (isHandshakeFrame(data)) {
362
+ resolveReply(data)
363
+ }
364
+ }
365
+ notifyCharacteristic.on('data', onData)
366
+ const timer = setTimeout(
367
+ () => rejectReply(new Error('Encryption handshake timeout')),
368
+ HANDSHAKE_TIMEOUT,
369
+ )
370
+
371
+ try {
372
+ const { frame, txIvKey } = buildHandshake()
373
+ accessory.logDebug(`sending handshake: ${frame.toString('hex')}`)
374
+ await this.writeWithTimeout(writeCharacteristic, frame, WRITE_TIMEOUT)
375
+
376
+ const response = await reply
377
+ accessory.logDebug(`handshake reply: ${response.toString('hex')}`)
378
+
379
+ const { devInfo, sku, mac } = parseHandshakeResponse(response)
380
+ accessory.logDebug(`handshake verified for ${sku} at ${mac}`)
381
+
382
+ return new V2Session({ txIvKey, devInfo })
383
+ } finally {
384
+ clearTimeout(timer)
385
+ notifyCharacteristic.removeListener('data', onData)
386
+ }
387
+ }
388
+
302
389
  async connectWithTimeout(address, timeout) {
303
390
  let timeoutId
304
391
  try {
305
392
  return await Promise.race([
306
- btClient.connectAsync(address),
393
+ process.platform === 'darwin'
394
+ ? this.connectByNameDarwin(address, timeout)
395
+ : btClient.connectAsync(address),
307
396
  new Promise((_, reject) => {
308
397
  timeoutId = setTimeout(() => reject(new Error('Connection timeout')), timeout)
309
398
  }),
@@ -313,6 +402,54 @@ export default class BLEConnection {
313
402
  }
314
403
  }
315
404
 
405
+ // macOS: CoreBluetooth never exposes MAC addresses, so connect-by-address can
406
+ // never resolve. Govee devices advertise a local name whose suffix is the last
407
+ // two bytes of their MAC (e.g. Govee_H617A_325A for ...:32:5A), so scan and
408
+ // match on that instead, then connect to the discovered peripheral directly.
409
+ async connectByNameDarwin(address, timeout) {
410
+ const suffix = address.replace(/:/g, '').slice(-4).toLowerCase()
411
+ this.darwinPeripheralCache = this.darwinPeripheralCache ?? new Map()
412
+ const cached = this.darwinPeripheralCache.get(suffix)
413
+ if (cached) {
414
+ try {
415
+ await cached.connectAsync()
416
+ return cached
417
+ } catch (err) {
418
+ this.darwinPeripheralCache.delete(suffix)
419
+ this.log.debug('[BLE] cached peripheral for %s failed (%s), rescanning.', suffix, err.message)
420
+ }
421
+ }
422
+ const peripheral = await new Promise((resolve, reject) => {
423
+ let done = false
424
+ let scanTimeoutId
425
+ function onDiscover(p) {
426
+ const name = (p.advertisement?.localName ?? '').toLowerCase()
427
+ if (name.endsWith(suffix) && (name.startsWith('govee') || name.startsWith('ihoment'))) {
428
+ finish(resolve, p)
429
+ }
430
+ }
431
+ function finish(fn, value) {
432
+ if (done) {
433
+ return
434
+ }
435
+ done = true
436
+ // The device is usually found in well under a second, so without this
437
+ // the timer stays live for the rest of its window and holds the event
438
+ // loop open. The two timeouts above clear theirs the same way.
439
+ clearTimeout(scanTimeoutId)
440
+ btClient.removeListener('discover', onDiscover)
441
+ btClient.stopScanningAsync().catch(() => {})
442
+ fn(value)
443
+ }
444
+ btClient.on('discover', onDiscover)
445
+ btClient.startScanningAsync([], true).catch(err => finish(reject, err))
446
+ scanTimeoutId = setTimeout(() => finish(reject, new Error(`no peripheral advertising name suffix ${suffix} found in scan`)), Math.max(timeout - 1000, 4000))
447
+ })
448
+ this.darwinPeripheralCache.set(suffix, peripheral)
449
+ await peripheral.connectAsync()
450
+ return peripheral
451
+ }
452
+
316
453
  async writeWithTimeout(characteristic, buffer, timeout) {
317
454
  let timeoutId
318
455
  try {
@@ -3,6 +3,7 @@ import mqtt from 'mqtt'
3
3
 
4
4
  import { k2rgb } from '../utils/colour.js'
5
5
  import { describeControlFailure, parseError } from '../utils/functions.js'
6
+ import { isFirstReport } from '../utils/report-unknown.js'
6
7
 
7
8
  const BASE_URL = 'https://openapi.api.govee.com'
8
9
 
@@ -381,6 +382,23 @@ export default class {
381
382
  if (capability?.type && capability?.instance) {
382
383
  return capability
383
384
  }
385
+
386
+ // The device never listed this one. Govee will still accept the request and
387
+ // answer as though it worked, then do nothing - so without saying something
388
+ // here the control simply appears broken with no clue why. Said once per
389
+ // device and capability, then dropped to debug
390
+ const fingerprint = `openapi-unlisted|${accessory.context.gvDeviceId}|${instance}`
391
+ const message = `govee did not list [${instance}] for this device, so that command may be ignored - please report this line if the control does not work: ${JSON.stringify({
392
+ model: accessory.context.gvModel,
393
+ instance,
394
+ listed: Object.keys(lookup),
395
+ })}`
396
+ if (isFirstReport(fingerprint)) {
397
+ accessory.logDebugWarn(message)
398
+ } else {
399
+ accessory.logDebug(message)
400
+ }
401
+
384
402
  return {
385
403
  type: fallbackType,
386
404
  instance,
@@ -1,6 +1,8 @@
1
- import { base64ToHex, getTwoItemPosition, hexToTwoItems, parseError } from '../utils/functions.js'
1
+ import { describeSources, resolveControls } from '../utils/capabilities.js'
2
+ import { base64ToHex, getTwoItemPosition, hexToTwoItems } from '../utils/functions.js'
2
3
  import platformLang from '../utils/lang-en.js'
3
4
  import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknown.js'
5
+ import GoveeDevice from './base.js'
4
6
 
5
7
  /**
6
8
  * A starting point for any appliance whose power command is known but whose
@@ -19,25 +21,32 @@ import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknow
19
21
  *
20
22
  * Once that data arrives the model gets a proper handler and stops using this.
21
23
  */
22
- class ApplianceBasic {
24
+ class ApplianceBasic extends GoveeDevice {
23
25
  constructor(platform, accessory, config) {
24
- // Set up variables from the platform
25
- this.hapChar = platform.api.hap.Characteristic
26
- this.hapErr = platform.api.hap.HapStatusError
27
- this.hapServ = platform.api.hap.Service
28
- this.platform = platform
29
-
26
+ super(platform, accessory)
30
27
  // Set up variables from the accessory
31
- this.accessory = accessory
32
28
  this.config = config
33
29
 
30
+ // Work out what this particular device can do, rather than what its model
31
+ // generally can - for a model nobody has written down, what the device
32
+ // itself reported is all there is to go on
33
+ const resolved = resolveControls({
34
+ model: accessory.context.gvModel,
35
+ family: config.family,
36
+ firmware: accessory.context.firmware,
37
+ openApiCapabilities: accessory.context.openApiCapabilities,
38
+ deviceConf: platform.deviceConf?.[accessory.context.gvDeviceId],
39
+ })
40
+ this.controls = resolved.controls
41
+ this.cacheToggles = {}
42
+ this.discoveredControls = []
43
+
34
44
  // Some appliances can have a richer tile, but only if the device reports
35
45
  // the reading that tile needs - HomeKit's humidifier, for example, has to
36
46
  // show a current humidity, and plenty of Govee humidifiers never send one.
37
- // Where Govee tells us the device has that sensor, use the better tile;
38
- // otherwise fall back to the plain one, which needs nothing extra.
39
- const hasSensor = config.richCapability
40
- && !!accessory.context.openApiCapabilities?.[config.richCapability]
47
+ // Where the device says it has that sensor, use the better tile; otherwise
48
+ // fall back to the plain one, which needs nothing extra.
49
+ const hasSensor = config.richControl && this.controls[config.richControl]
41
50
  const serviceName = (config.richService && hasSensor) ? config.richService : config.service
42
51
  this.usingRichService = serviceName === config.richService
43
52
 
@@ -57,7 +66,7 @@ class ApplianceBasic {
57
66
  // The plain Fan service switches on a boolean `On`, while Fanv2 and
58
67
  // AirPurifier use `Active`. Follow whichever this service uses, so these
59
68
  // tiles behave like the hand-written handlers for the same kind of device.
60
- const usesOn = serviceName === 'Fan'
69
+ const usesOn = serviceName === 'Fan' || serviceName === 'Switch'
61
70
  this.powerChar = usesOn ? this.hapChar.On : this.hapChar.Active
62
71
  this.powerOn = usesOn ? true : 1
63
72
  this.powerOff = usesOn ? false : 0
@@ -94,10 +103,76 @@ class ApplianceBasic {
94
103
  this.cacheVariable = this.service.getCharacteristic(this.variableChar).value
95
104
  }
96
105
 
106
+ // A control the device told us about can be driven through Govee's own api
107
+ // by name, without knowing this model's command bytes. So unlike the
108
+ // settings above, these work rather than asking to be reported.
109
+ this.addDiscoveredToggle('swing', 'SwingMode', 'oscillationToggle')
110
+ this.addDiscoveredToggle('lock', 'LockPhysicalControls', 'lockToggle')
111
+
97
112
  // Output the customised options to the log
98
113
  const opts = JSON.stringify({})
99
114
  platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
100
- this.accessory.log(`on and off are supported for this model, the ${config.variableLabel || 'other settings'} are not known yet`)
115
+ this.accessory.logDebug(`controls: ${describeSources(resolved.sources)}`)
116
+
117
+ const working = ['on and off', ...this.discoveredControls]
118
+ this.accessory.log(`${working.join(', ')} ${working.length > 1 ? 'are' : 'is'} supported for this model, the ${config.variableLabel || 'other settings'} are not known yet`)
119
+ }
120
+
121
+ /**
122
+ * Offer a control the device said it has, driven through Govee's api by the
123
+ * name the device gave it.
124
+ *
125
+ * This is the one place a model nobody has tested gets something that really
126
+ * works beyond power: the api takes the capability name, so the command bytes
127
+ * for this model never have to be known.
128
+ */
129
+ addDiscoveredToggle(control, characteristic, instance) {
130
+ const char = this.hapChar[characteristic]
131
+
132
+ // The device has to have named this capability itself. Knowing the model
133
+ // usually has a turning head is not enough - the api is driven by the name
134
+ // the device reported, so without it there is nothing to send, and the
135
+ // switch would fail every time it was touched.
136
+ const reported = !!this.accessory.context.openApiCapabilities?.[instance]
137
+
138
+ if (!reported || !this.controls[control] || !char) {
139
+ if (this.service.testCharacteristic?.(char)) {
140
+ this.service.removeCharacteristic(this.service.getCharacteristic(char))
141
+ }
142
+ return
143
+ }
144
+
145
+ this.service
146
+ .getCharacteristic(char)
147
+ .onSet(async value => this.internalToggleUpdate(control, char, instance, value))
148
+ this.cacheToggles[control] = this.service.getCharacteristic(char).value
149
+
150
+ this.discoveredControls.push(control === 'swing' ? 'oscillation' : 'the child lock')
151
+ }
152
+
153
+ async internalToggleUpdate(control, characteristic, instance, value) {
154
+ const previous = this.cacheToggles[control]
155
+ try {
156
+ if (previous === value) {
157
+ return
158
+ }
159
+
160
+ await this.platform.sendDeviceUpdate(this.accessory, {
161
+ cmd: 'openApi',
162
+ openApi: {
163
+ instance,
164
+ capabilityType: 'devices.capabilities.toggle',
165
+ value: value ? 1 : 0,
166
+ },
167
+ })
168
+
169
+ this.cacheToggles[control] = value
170
+ this.accessory.log(`current ${control} [${value ? 'on' : 'off'}]`)
171
+ } catch (err) {
172
+ this.failUpdate(err, () => {
173
+ this.service.updateCharacteristic(characteristic, previous)
174
+ })
175
+ }
101
176
  }
102
177
 
103
178
  async internalStateUpdate(value) {
@@ -129,14 +204,9 @@ class ApplianceBasic {
129
204
  }
130
205
  this.accessory.log(`${platformLang.curState} [${newValue}]`)
131
206
  } catch (err) {
132
- // Catch any errors during the process
133
- this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
134
-
135
- // Throw a 'no response' error and set a timeout to revert this after 2 seconds
136
- setTimeout(() => {
207
+ this.failUpdate(err, () => {
137
208
  this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
138
- }, 2000)
139
- throw new this.hapErr(-70402)
209
+ })
140
210
  }
141
211
  }
142
212
 
@@ -229,6 +299,7 @@ export function makeBasicAppliance(config) {
229
299
 
230
300
  // Fan-shaped appliances: a speed slider that is not worked out yet
231
301
  export const deviceFanBasic = makeBasicAppliance({
302
+ family: 'fan',
232
303
  service: 'Fanv2',
233
304
  variableChar: 'RotationSpeed',
234
305
  variableLabel: 'fan speed',
@@ -236,6 +307,7 @@ export const deviceFanBasic = makeBasicAppliance({
236
307
 
237
308
  // Ice makers reuse the fan tile, with the slider standing in for ice size
238
309
  export const deviceIceMakerBasic = makeBasicAppliance({
310
+ family: 'iceMaker',
239
311
  service: 'Fanv2',
240
312
  variableChar: 'RotationSpeed',
241
313
  variableLabel: 'ice size',
@@ -245,11 +317,12 @@ export const deviceIceMakerBasic = makeBasicAppliance({
245
317
  // has to show a current humidity and many Govee humidifiers never report one.
246
318
  // A model that does report it gets the proper humidifier tile instead.
247
319
  export const deviceHumidifierBasic = makeBasicAppliance({
320
+ family: 'humidifier',
248
321
  service: 'Fan',
249
322
  variableChar: 'RotationSpeed',
250
323
  variableLabel: 'mist level',
251
324
  richService: 'HumidifierDehumidifier',
252
- richCapability: 'sensorHumidity',
325
+ richControl: 'humiditySensor',
253
326
  richVariableChar: 'RelativeHumidityHumidifierThreshold',
254
327
  richReadingChar: 'CurrentRelativeHumidity',
255
328
  currentStateChar: 'CurrentHumidifierDehumidifierState',
@@ -258,11 +331,12 @@ export const deviceHumidifierBasic = makeBasicAppliance({
258
331
  })
259
332
 
260
333
  export const deviceDehumidifierBasic = makeBasicAppliance({
334
+ family: 'dehumidifier',
261
335
  service: 'Fan',
262
336
  variableChar: 'RotationSpeed',
263
337
  variableLabel: 'fan speed',
264
338
  richService: 'HumidifierDehumidifier',
265
- richCapability: 'sensorHumidity',
339
+ richControl: 'humiditySensor',
266
340
  richVariableChar: 'RelativeHumidityDehumidifierThreshold',
267
341
  richReadingChar: 'CurrentRelativeHumidity',
268
342
  currentStateChar: 'CurrentHumidifierDehumidifierState',
@@ -270,7 +344,25 @@ export const deviceDehumidifierBasic = makeBasicAppliance({
270
344
  currentStateOff: 0, // inactive
271
345
  })
272
346
 
347
+ // Diffusers are power only, so there is no variable control to offer. They use
348
+ // the same tile as the models already handled, so a new one looks the same
349
+ export const deviceDiffuserBasic = makeBasicAppliance({
350
+ family: 'diffuser',
351
+ service: 'AirPurifier',
352
+ currentStateChar: 'CurrentAirPurifierState',
353
+ currentStateOn: 2, // purifying air
354
+ currentStateOff: 0, // inactive
355
+ })
356
+
357
+ // A composter is on or off. It has a mode and a light too, but neither is
358
+ // worked out, so they are left off rather than shown as controls that do nothing
359
+ export const deviceComposterBasic = makeBasicAppliance({
360
+ family: 'composter',
361
+ service: 'Switch',
362
+ })
363
+
273
364
  export const devicePurifierBasic = makeBasicAppliance({
365
+ family: 'purifier',
274
366
  service: 'AirPurifier',
275
367
  variableChar: 'RotationSpeed',
276
368
  variableLabel: 'fan speed',
@@ -0,0 +1,80 @@
1
+ import { parseError } from '../utils/functions.js'
2
+ import platformLang from '../utils/lang-en.js'
3
+
4
+ /**
5
+ * What every device handler needs, in one place.
6
+ *
7
+ * Each handler used to open with the same five assignments, build its tiles the
8
+ * same way, and end every control with the same six-line failure block. That
9
+ * last one was written out a hundred times over, which meant the delay before a
10
+ * control snaps back, and the error HomeKit is given, were a hundred separate
11
+ * decisions that happened to agree.
12
+ */
13
+ export default class GoveeDevice {
14
+ constructor(platform, accessory) {
15
+ this.hapChar = platform.api.hap.Characteristic
16
+ this.hapErr = platform.api.hap.HapStatusError
17
+ this.hapServ = platform.api.hap.Service
18
+ this.platform = platform
19
+ this.accessory = accessory
20
+ }
21
+
22
+ /**
23
+ * The line at startup saying which of this device's settings are not the
24
+ * defaults.
25
+ */
26
+ logInitOptions(options = {}) {
27
+ this.platform.log(
28
+ '[%s] %s %s.',
29
+ this.accessory.displayName,
30
+ platformLang.devInitOpts,
31
+ JSON.stringify(options),
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Get a tile, making it if it is not there yet.
37
+ */
38
+ getOrAddService(serviceType, name, subtype) {
39
+ return this.accessory.getService(name ?? serviceType)
40
+ || this.accessory.addService(serviceType, name, subtype)
41
+ }
42
+
43
+ /**
44
+ * Drop a tile if the device has one, for when a setting has changed what this
45
+ * device should look like. Leaving the old one behind gives the owner a tile
46
+ * that no longer does anything.
47
+ */
48
+ removeServiceIfPresent(serviceType) {
49
+ const existing = this.accessory.getService(serviceType)
50
+ if (existing) {
51
+ this.accessory.removeService(existing)
52
+ }
53
+ return !!existing
54
+ }
55
+
56
+ /**
57
+ * Give up on a control, put it back the way it was, and tell HomeKit.
58
+ *
59
+ * The three parts matter together: the log line says what went wrong, the
60
+ * revert stops the tile showing a state the device is not in, and the error
61
+ * is what makes HomeKit show it as not responding rather than quietly
62
+ * accepting a change that never happened.
63
+ *
64
+ * `revert` is a function rather than a value so it is read when it runs, two
65
+ * seconds later, which is the behaviour this replaced.
66
+ *
67
+ * @param {Error} err whatever went wrong
68
+ * @param {Function} [revert] puts the control back
69
+ * @throws {object} always - the caller is expected to let this bubble up
70
+ */
71
+ failUpdate(err, revert) {
72
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
73
+
74
+ if (revert) {
75
+ setTimeout(revert, 2000)
76
+ }
77
+
78
+ throw new this.hapErr(-70402)
79
+ }
80
+ }