@homebridge-plugins/homebridge-govee 11.32.2-beta.8 → 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.
- package/CHANGELOG.md +32 -2
- package/README.md +1 -1
- package/lib/connection/ble.js +156 -19
- package/lib/connection/openapi.js +18 -0
- package/lib/device/appliance-basic.js +116 -24
- package/lib/device/base.js +80 -0
- package/lib/device/cooler-single.js +7 -22
- package/lib/device/dehumidifier-H7150.js +8 -26
- package/lib/device/diffuser-H7161.js +5 -18
- package/lib/device/fan-H1370.js +13 -46
- package/lib/device/fan-H7100.js +36 -33
- package/lib/device/fan-H7105.js +15 -53
- package/lib/device/fan-H7107.js +15 -53
- package/lib/device/heater-single.js +7 -22
- package/lib/device/heater1a.js +21 -42
- package/lib/device/heater1b.js +17 -61
- package/lib/device/heater2.js +23 -81
- package/lib/device/humidifier-H7140.js +9 -32
- package/lib/device/humidifier-H7141.js +7 -25
- package/lib/device/humidifier-H7142.js +13 -46
- package/lib/device/humidifier-H7143.js +7 -25
- package/lib/device/humidifier-H7160.js +13 -46
- package/lib/device/ice-maker-H7172.js +18 -29
- package/lib/device/index.js +22 -29
- package/lib/device/kettle.js +5 -16
- package/lib/device/light-switch.js +5 -18
- package/lib/device/light.js +14 -41
- package/lib/device/outlet-single.js +8 -18
- package/lib/device/purifier-H7120.js +20 -68
- package/lib/device/purifier-H7122.js +14 -45
- package/lib/device/purifier-H7123.js +15 -46
- package/lib/device/purifier-H7126.js +12 -38
- package/lib/device/purifier-single.js +7 -19
- package/lib/device/sensor-basic.js +25 -0
- package/lib/device/sensor-co2.js +5 -11
- package/lib/device/sensor-leak.js +4 -9
- package/lib/device/sensor-monitor.js +3 -10
- package/lib/device/sensor-presence.js +3 -10
- package/lib/device/sensor-probe.js +52 -0
- package/lib/device/sensor-thermo-switch.js +4 -9
- package/lib/device/sensor-thermo.js +4 -9
- package/lib/device/switch-double.js +39 -32
- package/lib/device/switch-single.js +6 -19
- package/lib/device/switch-triple.js +41 -35
- package/lib/device/tap-single.js +7 -19
- package/lib/device/template.js +3 -10
- package/lib/device/tv-single.js +6 -18
- package/lib/device/valve-single.js +7 -19
- package/lib/platform.js +107 -813
- package/lib/utils/ble-crypto.js +130 -0
- package/lib/utils/ble-protocol.js +8 -0
- package/lib/utils/capabilities.js +221 -0
- package/lib/utils/command-builder.js +2 -0
- package/lib/utils/config-parser.js +280 -0
- package/lib/utils/constants.js +28 -6
- package/lib/utils/device-capabilities.js +34 -1
- package/lib/utils/device-context.js +179 -0
- package/lib/utils/device-merge.js +251 -0
- package/lib/utils/dispatch.js +161 -0
- package/lib/utils/functions.js +9 -0
- package/package.json +1 -1
- package/CONTRIBUTING-STRUCTURE.md +0 -66
- package/lib/device/dehumidifier-H7151.js +0 -195
- package/lib/device/dehumidifier-H7152.js +0 -186
- package/lib/device/fan-H1310.js +0 -290
- package/lib/device/fan-H7101.js +0 -346
- package/lib/device/fan-H7111.js +0 -351
- package/lib/device/ice-maker-H8121.js +0 -228
- package/lib/device/ice-maker-H8122.js +0 -228
- package/lib/device/outlet-double.js +0 -188
- package/lib/device/outlet-triple.js +0 -160
- package/lib/device/purifier-H7121.js +0 -412
- package/lib/device/purifier-H7124.js +0 -510
- package/lib/device/sensor-button.js +0 -27
- package/lib/device/sensor-contact.js +0 -27
- package/lib/device/sensor-thermo4.js +0 -66
- 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 (
|
|
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,7 +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:
|
|
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
|
|
24
54
|
|
|
25
55
|
## v11.32.1 (2026-08-02)
|
|
26
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
|
|
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
|
|
package/lib/connection/ble.js
CHANGED
|
@@ -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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
38
|
-
//
|
|
39
|
-
const hasSensor = config.
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|