@homebridge-plugins/homebridge-govee 11.32.2-beta.3 → 11.32.2-beta.30

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 (94) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/config.schema.json +1 -1
  3. package/lib/connection/ble.js +113 -2
  4. package/lib/connection/openapi.js +18 -0
  5. package/lib/device/appliance-basic.js +372 -0
  6. package/lib/device/base.js +80 -0
  7. package/lib/device/cooler-single.js +7 -22
  8. package/lib/device/dehumidifier-H7150.js +15 -27
  9. package/lib/device/diffuser-H7161.js +12 -19
  10. package/lib/device/fan-H1370.js +20 -47
  11. package/lib/device/fan-H7100.js +43 -34
  12. package/lib/device/fan-H7105.js +22 -54
  13. package/lib/device/fan-H7107.js +22 -54
  14. package/lib/device/heater-single.js +7 -22
  15. package/lib/device/heater1a.js +63 -52
  16. package/lib/device/heater1b.js +31 -64
  17. package/lib/device/heater2.js +30 -82
  18. package/lib/device/humidifier-H7140.js +16 -33
  19. package/lib/device/humidifier-H7141.js +14 -26
  20. package/lib/device/humidifier-H7142.js +20 -47
  21. package/lib/device/humidifier-H7143.js +14 -26
  22. package/lib/device/humidifier-H7160.js +20 -47
  23. package/lib/device/ice-maker-H7172.js +25 -30
  24. package/lib/device/index.js +48 -57
  25. package/lib/device/kettle.js +12 -17
  26. package/lib/device/light-switch.js +5 -18
  27. package/lib/device/light.js +14 -41
  28. package/lib/device/outlet-single.js +15 -19
  29. package/lib/device/purifier-H7120.js +27 -69
  30. package/lib/device/purifier-H7122.js +21 -46
  31. package/lib/device/purifier-H7123.js +22 -47
  32. package/lib/device/purifier-H7126.js +19 -39
  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 +10 -11
  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 +18 -12
  47. package/lib/device/tv-single.js +6 -18
  48. package/lib/device/valve-single.js +7 -19
  49. package/lib/platform.js +100 -799
  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 +6 -4
  54. package/lib/utils/config-parser.js +280 -0
  55. package/lib/utils/constants.js +148 -14
  56. package/lib/utils/decode.js +4 -1
  57. package/lib/utils/device-capabilities.js +95 -3
  58. package/lib/utils/device-context.js +179 -0
  59. package/lib/utils/device-merge.js +251 -0
  60. package/lib/utils/dispatch.js +161 -0
  61. package/lib/utils/functions.js +9 -0
  62. package/lib/utils/lang-en.js +0 -2
  63. package/lib/utils/report-unknown.js +156 -0
  64. package/package.json +1 -1
  65. package/lib/device/dehumidifier-H7151.js +0 -189
  66. package/lib/device/dehumidifier-H7152.js +0 -180
  67. package/lib/device/diffuser-H7162.js +0 -123
  68. package/lib/device/fan-H1310.js +0 -284
  69. package/lib/device/fan-H7101.js +0 -340
  70. package/lib/device/fan-H7102.js +0 -284
  71. package/lib/device/fan-H7106.js +0 -284
  72. package/lib/device/fan-H7111.js +0 -345
  73. package/lib/device/humidifier-H7145.js +0 -187
  74. package/lib/device/humidifier-H7147.js +0 -187
  75. package/lib/device/humidifier-H7148.js +0 -187
  76. package/lib/device/humidifier-H7149.js +0 -187
  77. package/lib/device/humidifier-H714E.js +0 -187
  78. package/lib/device/ice-maker-H717D.js +0 -218
  79. package/lib/device/ice-maker-H8120.js +0 -218
  80. package/lib/device/ice-maker-H8121.js +0 -222
  81. package/lib/device/ice-maker-H8122.js +0 -222
  82. package/lib/device/outlet-double.js +0 -188
  83. package/lib/device/outlet-triple.js +0 -160
  84. package/lib/device/purifier-H7121.js +0 -406
  85. package/lib/device/purifier-H7124.js +0 -504
  86. package/lib/device/purifier-H7127.js +0 -359
  87. package/lib/device/purifier-H7128.js +0 -359
  88. package/lib/device/purifier-H7129.js +0 -359
  89. package/lib/device/purifier-H712C.js +0 -359
  90. package/lib/device/sensor-button.js +0 -22
  91. package/lib/device/sensor-contact.js +0 -22
  92. package/lib/device/sensor-thermo4.js +0 -55
  93. package/lib/utils/ble-timeouts.test.js +0 -44
  94. package/lib/utils/functions.test.js +0 -292
package/CHANGELOG.md CHANGED
@@ -6,10 +6,46 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
6
6
 
7
7
  ### Changed
8
8
 
9
+ - fix: stop shipping the test files to npm, since they are only needed in the repository
9
10
  - fix: implement the H7107's oscillation toggle, which threw a not-implemented error when tapped (#1334)
10
11
  - 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
12
  - fix: send the H7107's oscillation-off command without the swing-range bytes, matching the zero-tailed frame the fan echoes back (#1334)
12
- - feat: add support for the H8121 and H8122 ice makers, with ice size derived from the govee app (pending owner confirmation)
13
+ - feat: add support for the H8121 and H8122 ice makers, with small, medium and large ice sizes (pending owner confirmation)
14
+ - fix: send the H6102 the colour command its firmware expects, since the previous one was accepted but ignored (#1332)
15
+ - fix: read the H5179 sensor's temperature from its own bytes, since it was picking up part of the humidity and reporting slightly wrong
16
+ - feat: log govee sensors whose bluetooth broadcast is not recognised, so unsupported models can be identified
17
+ - feat: report unrecognised device data in one line with the model, firmware and connection, so it can be sent in without follow-up questions
18
+ - feat: work out for itself whether a heater reports the room temperature, and add the H7137 and H713E
19
+ - feat: add support for 85 more light models, covering everything currently sold that was missing
20
+ - feat: add 24 more light models, three thermo-hygrometers and a leak detector
21
+ - feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
22
+ - feat: give every appliance type a starting handler, so a new model works for on and off and asks for the rest
23
+ - refactor: point models that behave identically at one shared handler instead of copies of the same file
24
+ - fix: refuse to send a bluetooth command too long to fit in a packet, rather than sending a longer one the device quietly ignores
25
+ - test: record what every supported model turns into, so a change can be shown to have altered nothing
26
+ - 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
27
+ - 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
28
+ - 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
29
+ - 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)
30
+ - refactor: point the H7151 and H7111 at the handlers they were byte-for-byte copies of
31
+ - refactor: build the ice maker size codes from the model rather than writing both orderings out by hand
32
+ - refactor: build the switch and outlet tiles of a multi-socket device from one handler instead of two copies of it
33
+ - fix: move the H7152 dehumidifier's speed slider when it reports a new speed, which until now only the H7150 and H7151 did
34
+ - refactor: point the H7101 and H7111 fans at the H7100 handler they matched command for command
35
+ - 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
36
+ - refactor: choose a device handler from a table, so adding a device type no longer means editing the platform
37
+ - refactor: move the settings parsing into its own file, away from the code that talks to devices
38
+ - refactor: move the work of describing a device to its handler into its own file
39
+ - 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
40
+ - test: check what the plugin sends over aws, the local network and the public api, none of which was covered before
41
+ - 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
42
+ - docs: note that the local network api accepts the raw command form, which is how scenes work without the cloud
43
+ - 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
44
+ - 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
45
+ - feat: add the H5161 power strip and give the H7184 kitchen composter an on and off switch
46
+ - feat: recognise three more sensors, which report what they send, and say plainly in the log why a sensor has no tile yet
47
+ - feat: add ble encryption for the H3001 solar string lights, which silently ignore unencrypted frames (#1328) (@dominick-han)
48
+ - fix: mention an unrecognised bluetooth broadcast once, and only when the device is on your own account, since a scan also hears the neighbours
13
49
 
14
50
  ## v11.32.1 (2026-08-02)
15
51
 
@@ -1260,7 +1260,7 @@
1260
1260
  "tempReporting": {
1261
1261
  "type": "boolean",
1262
1262
  "title": "Temperature Reporting",
1263
- "description": "Enable this if your H7130 device reports ambient temperature.",
1263
+ "description": "Only needed to override the plugin. It works out on its own whether this heater reports the room temperature, and picks the right tile.",
1264
1264
  "condition": {
1265
1265
  "functionBody": "return (model.heaterDevices && model.heaterDevices[arrayIndices] && model.heaterDevices[arrayIndices].deviceId && model.heaterDevices[arrayIndices].deviceId.length === 23);"
1266
1266
  }
@@ -3,10 +3,13 @@ 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'
12
+ import { isFirstReport } from '../utils/report-unknown.js'
10
13
  import { isValidPeripheral } from '../utils/validation.js'
11
14
 
12
15
  const HYPHEN_REGEX = /-/g
@@ -19,6 +22,7 @@ const H5101_UUID = '0001'
19
22
  const POWER_ON_TIMEOUT = BLE_STAGE_TIMEOUTS.powerOn
20
23
  const CONNECTION_TIMEOUT = BLE_STAGE_TIMEOUTS.connect
21
24
  const WRITE_TIMEOUT = BLE_STAGE_TIMEOUTS.write
25
+ const HANDSHAKE_TIMEOUT = BLE_STAGE_TIMEOUTS.handshake
22
26
  const SETTLE_DELAY = BLE_STAGE_TIMEOUTS.settle
23
27
 
24
28
  /*
@@ -120,8 +124,40 @@ export default class BLEConnection {
120
124
  try {
121
125
  const { uuid, address, rssi, advertisement } = peripheral
122
126
 
123
- // Skip if not a valid Govee sensor
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.
124
130
  if (!isValidPeripheral(peripheral)) {
131
+ const unknownName = advertisement?.localName
132
+ if (unknownName && unknownName.toLowerCase().startsWith('govee')) {
133
+ const raw = advertisement.manufacturerData
134
+ ? advertisement.manufacturerData.toString('hex')
135
+ : 'no manufacturer data'
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.
141
+ if (isFirstReport(`ble|${address}|${raw}`)) {
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
+ // Bluetooth is a broadcast medium, so this scan hears every Govee
151
+ // device in range - including a neighbour's, which is not something
152
+ // the owner can do anything about. Only a device on this account is
153
+ // worth putting in front of them.
154
+ if (this.platform.hasBleDevice(address)) {
155
+ this.log.warn(line)
156
+ } else {
157
+ this.log.debug(line)
158
+ }
159
+ }
160
+ }
125
161
  return
126
162
  }
127
163
 
@@ -240,8 +276,19 @@ export default class BLEConnection {
240
276
  const finalBuffer = this.prepareCommandBuffer(params)
241
277
  accessory.logDebug(`sending command: ${finalBuffer.toString('hex')}`)
242
278
 
279
+ // Encrypted models refuse the plain frame. Because writes are
280
+ // unacknowledged, they refuse it silently, so the handshake has to happen
281
+ // here rather than being discovered by a failed write. It is per
282
+ // connection: the frame counter resets with the link.
283
+ let payload = finalBuffer
284
+ if (getDeviceCapabilities(accessory.context.gvModel).bleEncryption === 'v2') {
285
+ const session = await this.negotiateSession(accessory, characteristic, characteristics)
286
+ payload = session.seal(finalBuffer)
287
+ accessory.logDebug(`encrypted as: ${payload.toString('hex')}`)
288
+ }
289
+
243
290
  // Write without response then allow time for the BLE controller to transmit before disconnecting
244
- await this.writeWithTimeout(characteristic, finalBuffer, WRITE_TIMEOUT)
291
+ await this.writeWithTimeout(characteristic, payload, WRITE_TIMEOUT)
245
292
  accessory.logDebug('command sent successfully')
246
293
  await sleep(SETTLE_DELAY)
247
294
  } catch (err) {
@@ -274,6 +321,70 @@ export default class BLEConnection {
274
321
  }
275
322
  }
276
323
 
324
+ /**
325
+ * Exchange a session key with an encrypted model.
326
+ *
327
+ * Unlike every other path in this client we need a reply, which arrives as a
328
+ * notification on UUID.NOTIFY. Note that we deliberately do NOT subscribe:
329
+ * these lights push notifications without the client ever writing the client
330
+ * characteristic configuration descriptor.
331
+ * Calling subscribeAsync() here makes the descriptor write hang and the light
332
+ * then drops the connection, so listening is both sufficient and required.
333
+ *
334
+ * The reply is authenticated - parseHandshakeResponse throws if the GCM tag
335
+ * does not verify - which turns a wrong key into a real error rather than a
336
+ * light that silently ignores every command.
337
+ *
338
+ * Returns a V2Session that wraps outgoing frames.
339
+ */
340
+ async negotiateSession(accessory, writeCharacteristic, characteristics) {
341
+ const notifyCharacteristic = Object.values(characteristics).find(
342
+ char => char.uuid.replace(HYPHEN_REGEX, '') === UUID.NOTIFY,
343
+ )
344
+ if (!notifyCharacteristic) {
345
+ throw new Error('Notify characteristic not found, cannot negotiate encryption')
346
+ }
347
+
348
+ let resolveReply
349
+ let rejectReply
350
+ const reply = new Promise((resolve, reject) => {
351
+ resolveReply = resolve
352
+ rejectReply = reject
353
+ })
354
+ // Attach a handler immediately: if the write below throws, the timeout would
355
+ // otherwise reject an unawaited promise and crash the process.
356
+ reply.catch(() => {})
357
+
358
+ const onData = (data) => {
359
+ // The light also pushes state frames here; take only the handshake.
360
+ if (isHandshakeFrame(data)) {
361
+ resolveReply(data)
362
+ }
363
+ }
364
+ notifyCharacteristic.on('data', onData)
365
+ const timer = setTimeout(
366
+ () => rejectReply(new Error('Encryption handshake timeout')),
367
+ HANDSHAKE_TIMEOUT,
368
+ )
369
+
370
+ try {
371
+ const { frame, txIvKey } = buildHandshake()
372
+ accessory.logDebug(`sending handshake: ${frame.toString('hex')}`)
373
+ await this.writeWithTimeout(writeCharacteristic, frame, WRITE_TIMEOUT)
374
+
375
+ const response = await reply
376
+ accessory.logDebug(`handshake reply: ${response.toString('hex')}`)
377
+
378
+ const { devInfo, sku, mac } = parseHandshakeResponse(response)
379
+ accessory.logDebug(`handshake verified for ${sku} at ${mac}`)
380
+
381
+ return new V2Session({ txIvKey, devInfo })
382
+ } finally {
383
+ clearTimeout(timer)
384
+ notifyCharacteristic.removeListener('data', onData)
385
+ }
386
+ }
387
+
277
388
  async connectWithTimeout(address, timeout) {
278
389
  let timeoutId
279
390
  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,
@@ -0,0 +1,372 @@
1
+ import { describeSources, resolveControls } from '../utils/capabilities.js'
2
+ import { base64ToHex, getTwoItemPosition, hexToTwoItems } from '../utils/functions.js'
3
+ import platformLang from '../utils/lang-en.js'
4
+ import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknown.js'
5
+ import GoveeDevice from './base.js'
6
+
7
+ /**
8
+ * A starting point for any appliance whose power command is known but whose
9
+ * other controls are not.
10
+ *
11
+ * Govee uses the same on/off command for every appliance the plugin handles, so
12
+ * power can be offered with confidence for a model nobody has tested. The
13
+ * settings that differ per model - speeds, modes, sizes - are the risky part,
14
+ * and getting one wrong is worse than not offering it. So those controls appear
15
+ * but report what they need and fail the request, rather than sending a guess.
16
+ *
17
+ * Failing rather than doing nothing is deliberate: HomeKit shows the control as
18
+ * not responding, which is what sends the owner to the log, where they find a
19
+ * line to paste into an issue. That turns every owner of an untested model into
20
+ * the source of the data needed to finish it.
21
+ *
22
+ * Once that data arrives the model gets a proper handler and stops using this.
23
+ */
24
+ class ApplianceBasic extends GoveeDevice {
25
+ constructor(platform, accessory, config) {
26
+ super(platform, accessory)
27
+ // Set up variables from the accessory
28
+ this.config = config
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
+
44
+ // Some appliances can have a richer tile, but only if the device reports
45
+ // the reading that tile needs - HomeKit's humidifier, for example, has to
46
+ // show a current humidity, and plenty of Govee humidifiers never send one.
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]
50
+ const serviceName = (config.richService && hasSensor) ? config.richService : config.service
51
+ this.usingRichService = serviceName === config.richService
52
+
53
+ // Drop the other service if this device has switched between the two
54
+ const otherName = this.usingRichService ? config.service : config.richService
55
+ if (otherName && this.hapServ[otherName]) {
56
+ const stale = this.accessory.getService(this.hapServ[otherName])
57
+ if (stale) {
58
+ this.accessory.removeService(stale)
59
+ }
60
+ }
61
+
62
+ const serviceType = this.hapServ[serviceName]
63
+ this.service = this.accessory.getService(serviceType)
64
+ || this.accessory.addService(serviceType)
65
+
66
+ // The plain Fan service switches on a boolean `On`, while Fanv2 and
67
+ // AirPurifier use `Active`. Follow whichever this service uses, so these
68
+ // tiles behave like the hand-written handlers for the same kind of device.
69
+ const usesOn = serviceName === 'Fan' || serviceName === 'Switch'
70
+ this.powerChar = usesOn ? this.hapChar.On : this.hapChar.Active
71
+ this.powerOn = usesOn ? true : 1
72
+ this.powerOff = usesOn ? false : 0
73
+
74
+ // Add the set handler to the power characteristic
75
+ this.service
76
+ .getCharacteristic(this.powerChar)
77
+ .onSet(async value => this.internalStateUpdate(value))
78
+ this.cacheState = this.service.getCharacteristic(this.powerChar).value ? 'on' : 'off'
79
+
80
+ // Some services need a current-state characteristic to look right in the
81
+ // Home app. It belongs to the richer tile, so it is skipped when a device
82
+ // has fallen back to the plain fan for want of a sensor.
83
+ const fellBackToPlain = !!config.richService && !this.usingRichService
84
+ if (config.currentStateChar && !fellBackToPlain) {
85
+ this.currentStateChar = this.hapChar[config.currentStateChar]
86
+ this.service.updateCharacteristic(
87
+ this.currentStateChar,
88
+ this.cacheState === 'on' ? config.currentStateOn : config.currentStateOff,
89
+ )
90
+ }
91
+
92
+ // Offer the variable control so the device can be asked about it, but make
93
+ // it clear it does nothing yet rather than sending a guessed command. The
94
+ // richer services name this control differently, so follow the service.
95
+ const variableChar = this.usingRichService && config.richVariableChar
96
+ ? config.richVariableChar
97
+ : config.variableChar
98
+ if (variableChar) {
99
+ this.variableChar = this.hapChar[variableChar]
100
+ this.service
101
+ .getCharacteristic(this.variableChar)
102
+ .onSet(async value => this.internalVariableUpdate(value))
103
+ this.cacheVariable = this.service.getCharacteristic(this.variableChar).value
104
+ }
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
+
112
+ // Output the customised options to the log
113
+ const opts = JSON.stringify({})
114
+ platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
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
+ }
176
+ }
177
+
178
+ async internalStateUpdate(value) {
179
+ try {
180
+ const newValue = value ? 'on' : 'off'
181
+
182
+ // Don't continue if the new value is the same as before
183
+ if (this.cacheState === newValue) {
184
+ return
185
+ }
186
+
187
+ // This power command is the same for every govee appliance, so it is safe
188
+ // to send for a model that has not been tested
189
+ await this.platform.sendDeviceUpdate(this.accessory, {
190
+ cmd: 'ptReal',
191
+ value: value ? 'MwEBAAAAAAAAAAAAAAAAAAAAADM=' : 'MwEAAAAAAAAAAAAAAAAAAAAAADI=',
192
+ openApi: this.accessory.context.openApiCapabilities?.powerSwitch
193
+ ? { instance: 'powerSwitch', capabilityType: 'devices.capabilities.on_off', value: value ? 1 : 0 }
194
+ : undefined,
195
+ })
196
+
197
+ // Cache the new state and log if appropriate
198
+ this.cacheState = newValue
199
+ if (this.currentStateChar) {
200
+ this.service.updateCharacteristic(
201
+ this.currentStateChar,
202
+ value ? this.config.currentStateOn : this.config.currentStateOff,
203
+ )
204
+ }
205
+ this.accessory.log(`${platformLang.curState} [${newValue}]`)
206
+ } catch (err) {
207
+ this.failUpdate(err, () => {
208
+ this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
209
+ })
210
+ }
211
+ }
212
+
213
+ async internalVariableUpdate(value) {
214
+ // 0 is the control following the device being turned off, not a request
215
+ if (value === 0) {
216
+ return
217
+ }
218
+
219
+ reportUnsupportedControl(this.accessory, {
220
+ control: this.config.variableLabel || 'setting',
221
+ value,
222
+ })
223
+
224
+ // Put the control back, and fail the request so HomeKit shows it as not
225
+ // responding - that is what sends the owner to the log
226
+ setTimeout(() => {
227
+ this.service.updateCharacteristic(this.variableChar, this.cacheVariable)
228
+ }, 2000)
229
+ throw new this.hapErr(-70402)
230
+ }
231
+
232
+ externalUpdate(params) {
233
+ // Feed the sensor reading through when the richer tile is in use - HomeKit
234
+ // needs it, and it is the reason that tile was chosen
235
+ if (this.usingRichService && this.config.richReadingChar && params.humidity !== undefined) {
236
+ const reading = Number(params.humidity)
237
+ if (Number.isFinite(reading) && reading !== this.cacheReading) {
238
+ this.cacheReading = reading
239
+ this.service.updateCharacteristic(this.hapChar[this.config.richReadingChar], reading)
240
+ }
241
+ }
242
+
243
+ // Power is the one thing that can be read back with confidence
244
+ if (params.state && params.state !== this.cacheState) {
245
+ this.cacheState = params.state
246
+ this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
247
+ if (this.currentStateChar) {
248
+ this.service.updateCharacteristic(
249
+ this.currentStateChar,
250
+ this.cacheState === 'on' ? this.config.currentStateOn : this.config.currentStateOff,
251
+ )
252
+ }
253
+ this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
254
+ }
255
+
256
+ // Everything else gets reported so the model can be finished off
257
+ ;(params.commands || []).forEach((command) => {
258
+ const hexString = base64ToHex(command)
259
+ const hexParts = hexToTwoItems(hexString)
260
+
261
+ if (getTwoItemPosition(hexParts, 1) !== 'aa') {
262
+ return
263
+ }
264
+
265
+ // The power report is understood, anything else is worth sending in
266
+ if (getTwoItemPosition(hexParts, 2) === '01') {
267
+ const newState = getTwoItemPosition(hexParts, 3) === '01' ? 'on' : 'off'
268
+ if (this.cacheState !== newState) {
269
+ this.cacheState = newState
270
+ this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
271
+ this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
272
+ }
273
+ return
274
+ }
275
+
276
+ logUnknownData(this.accessory, {
277
+ kind: 'scene',
278
+ source: params.source,
279
+ raw: command,
280
+ hex: hexString,
281
+ })
282
+ })
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Build a basic handler for a kind of appliance.
288
+ *
289
+ * @param {object} config how this kind of appliance should appear
290
+ * @returns {Function} a handler class the platform can construct
291
+ */
292
+ export function makeBasicAppliance(config) {
293
+ return class extends ApplianceBasic {
294
+ constructor(platform, accessory) {
295
+ super(platform, accessory, config)
296
+ }
297
+ }
298
+ }
299
+
300
+ // Fan-shaped appliances: a speed slider that is not worked out yet
301
+ export const deviceFanBasic = makeBasicAppliance({
302
+ family: 'fan',
303
+ service: 'Fanv2',
304
+ variableChar: 'RotationSpeed',
305
+ variableLabel: 'fan speed',
306
+ })
307
+
308
+ // Ice makers reuse the fan tile, with the slider standing in for ice size
309
+ export const deviceIceMakerBasic = makeBasicAppliance({
310
+ family: 'iceMaker',
311
+ service: 'Fanv2',
312
+ variableChar: 'RotationSpeed',
313
+ variableLabel: 'ice size',
314
+ })
315
+
316
+ // Humidifiers get the plain fan tile by default, because HomeKit's humidifier
317
+ // has to show a current humidity and many Govee humidifiers never report one.
318
+ // A model that does report it gets the proper humidifier tile instead.
319
+ export const deviceHumidifierBasic = makeBasicAppliance({
320
+ family: 'humidifier',
321
+ service: 'Fan',
322
+ variableChar: 'RotationSpeed',
323
+ variableLabel: 'mist level',
324
+ richService: 'HumidifierDehumidifier',
325
+ richControl: 'humiditySensor',
326
+ richVariableChar: 'RelativeHumidityHumidifierThreshold',
327
+ richReadingChar: 'CurrentRelativeHumidity',
328
+ currentStateChar: 'CurrentHumidifierDehumidifierState',
329
+ currentStateOn: 2, // humidifying
330
+ currentStateOff: 0, // inactive
331
+ })
332
+
333
+ export const deviceDehumidifierBasic = makeBasicAppliance({
334
+ family: 'dehumidifier',
335
+ service: 'Fan',
336
+ variableChar: 'RotationSpeed',
337
+ variableLabel: 'fan speed',
338
+ richService: 'HumidifierDehumidifier',
339
+ richControl: 'humiditySensor',
340
+ richVariableChar: 'RelativeHumidityDehumidifierThreshold',
341
+ richReadingChar: 'CurrentRelativeHumidity',
342
+ currentStateChar: 'CurrentHumidifierDehumidifierState',
343
+ currentStateOn: 3, // dehumidifying
344
+ currentStateOff: 0, // inactive
345
+ })
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
+
364
+ export const devicePurifierBasic = makeBasicAppliance({
365
+ family: 'purifier',
366
+ service: 'AirPurifier',
367
+ variableChar: 'RotationSpeed',
368
+ variableLabel: 'fan speed',
369
+ currentStateChar: 'CurrentAirPurifierState',
370
+ currentStateOn: 2, // purifying air
371
+ currentStateOff: 0, // inactive
372
+ })