@homebridge-plugins/homebridge-govee 11.32.2-beta.25 → 11.32.2-beta.27
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 +2 -1
- package/lib/connection/ble.js +79 -1
- package/lib/utils/ble-crypto.js +130 -0
- package/lib/utils/ble-protocol.js +8 -0
- package/lib/utils/device-capabilities.js +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,7 @@ 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)
|
|
@@ -20,7 +21,6 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
20
21
|
- feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
|
|
21
22
|
- feat: give every appliance type a starting handler, so a new model works for on and off and asks for the rest
|
|
22
23
|
- 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
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
25
|
- test: record what every supported model turns into, so a change can be shown to have altered nothing
|
|
26
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
|
|
@@ -44,6 +44,7 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
44
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
45
|
- feat: add the H5161 power strip and give the H7184 kitchen composter an on and off switch
|
|
46
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)
|
|
47
48
|
|
|
48
49
|
## v11.32.1 (2026-08-02)
|
|
49
50
|
|
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
|
/*
|
|
@@ -265,8 +268,19 @@ export default class BLEConnection {
|
|
|
265
268
|
const finalBuffer = this.prepareCommandBuffer(params)
|
|
266
269
|
accessory.logDebug(`sending command: ${finalBuffer.toString('hex')}`)
|
|
267
270
|
|
|
271
|
+
// Encrypted models refuse the plain frame. Because writes are
|
|
272
|
+
// unacknowledged, they refuse it silently, so the handshake has to happen
|
|
273
|
+
// here rather than being discovered by a failed write. It is per
|
|
274
|
+
// connection: the frame counter resets with the link.
|
|
275
|
+
let payload = finalBuffer
|
|
276
|
+
if (getDeviceCapabilities(accessory.context.gvModel).bleEncryption === 'v2') {
|
|
277
|
+
const session = await this.negotiateSession(accessory, characteristic, characteristics)
|
|
278
|
+
payload = session.seal(finalBuffer)
|
|
279
|
+
accessory.logDebug(`encrypted as: ${payload.toString('hex')}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
268
282
|
// Write without response then allow time for the BLE controller to transmit before disconnecting
|
|
269
|
-
await this.writeWithTimeout(characteristic,
|
|
283
|
+
await this.writeWithTimeout(characteristic, payload, WRITE_TIMEOUT)
|
|
270
284
|
accessory.logDebug('command sent successfully')
|
|
271
285
|
await sleep(SETTLE_DELAY)
|
|
272
286
|
} catch (err) {
|
|
@@ -299,6 +313,70 @@ export default class BLEConnection {
|
|
|
299
313
|
}
|
|
300
314
|
}
|
|
301
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Exchange a session key with an encrypted model.
|
|
318
|
+
*
|
|
319
|
+
* Unlike every other path in this client we need a reply, which arrives as a
|
|
320
|
+
* notification on UUID.NOTIFY. Note that we deliberately do NOT subscribe:
|
|
321
|
+
* these lights push notifications without the client ever writing the client
|
|
322
|
+
* characteristic configuration descriptor, exactly as the official app does.
|
|
323
|
+
* Calling subscribeAsync() here makes the descriptor write hang and the light
|
|
324
|
+
* then drops the connection, so listening is both sufficient and required.
|
|
325
|
+
*
|
|
326
|
+
* The reply is authenticated - parseHandshakeResponse throws if the GCM tag
|
|
327
|
+
* does not verify - which turns a wrong key into a real error rather than a
|
|
328
|
+
* light that silently ignores every command.
|
|
329
|
+
*
|
|
330
|
+
* Returns a V2Session that wraps outgoing frames.
|
|
331
|
+
*/
|
|
332
|
+
async negotiateSession(accessory, writeCharacteristic, characteristics) {
|
|
333
|
+
const notifyCharacteristic = Object.values(characteristics).find(
|
|
334
|
+
char => char.uuid.replace(HYPHEN_REGEX, '') === UUID.NOTIFY,
|
|
335
|
+
)
|
|
336
|
+
if (!notifyCharacteristic) {
|
|
337
|
+
throw new Error('Notify characteristic not found, cannot negotiate encryption')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let resolveReply
|
|
341
|
+
let rejectReply
|
|
342
|
+
const reply = new Promise((resolve, reject) => {
|
|
343
|
+
resolveReply = resolve
|
|
344
|
+
rejectReply = reject
|
|
345
|
+
})
|
|
346
|
+
// Attach a handler immediately: if the write below throws, the timeout would
|
|
347
|
+
// otherwise reject an unawaited promise and crash the process.
|
|
348
|
+
reply.catch(() => {})
|
|
349
|
+
|
|
350
|
+
const onData = (data) => {
|
|
351
|
+
// The light also pushes state frames here; take only the handshake.
|
|
352
|
+
if (isHandshakeFrame(data)) {
|
|
353
|
+
resolveReply(data)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
notifyCharacteristic.on('data', onData)
|
|
357
|
+
const timer = setTimeout(
|
|
358
|
+
() => rejectReply(new Error('Encryption handshake timeout')),
|
|
359
|
+
HANDSHAKE_TIMEOUT,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const { frame, txIvKey } = buildHandshake()
|
|
364
|
+
accessory.logDebug(`sending handshake: ${frame.toString('hex')}`)
|
|
365
|
+
await this.writeWithTimeout(writeCharacteristic, frame, WRITE_TIMEOUT)
|
|
366
|
+
|
|
367
|
+
const response = await reply
|
|
368
|
+
accessory.logDebug(`handshake reply: ${response.toString('hex')}`)
|
|
369
|
+
|
|
370
|
+
const { devInfo, sku, mac } = parseHandshakeResponse(response)
|
|
371
|
+
accessory.logDebug(`handshake verified for ${sku} at ${mac}`)
|
|
372
|
+
|
|
373
|
+
return new V2Session({ txIvKey, devInfo })
|
|
374
|
+
} finally {
|
|
375
|
+
clearTimeout(timer)
|
|
376
|
+
notifyCharacteristic.removeListener('data', onData)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
302
380
|
async connectWithTimeout(address, timeout) {
|
|
303
381
|
let timeoutId
|
|
304
382
|
try {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Govee "V2" BLE encryption (EncryptionManagerV2 in the Govee Home app).
|
|
3
|
+
*
|
|
4
|
+
* Used by newer Bluetooth-only devices such as the H3001 solar string lights.
|
|
5
|
+
* Older devices take the plain 20-byte frame; these wrap it in AES-128-GCM and
|
|
6
|
+
* silently ignore anything that is not wrapped.
|
|
7
|
+
*
|
|
8
|
+
* Wire format on 00010203-0405-0607-0809-0a0b0c0d2b11 (write) and ...2b10 (notify):
|
|
9
|
+
*
|
|
10
|
+
* handshake request e7 11 01 | iv(12) | tagLen(1) | GCM{ txIvKey(8) }
|
|
11
|
+
* AAD = frame[0..15] key = KEY_HANDSHAKE
|
|
12
|
+
* handshake response e7 11 00 | iv(12) | GCM{ rxIvKey(8) ++ devInfo(11) }
|
|
13
|
+
* AAD = frame[0..14] key = KEY_HANDSHAKE
|
|
14
|
+
*
|
|
15
|
+
* deviceKey = AES-128-ECB-encrypt(KEY_DEVICE, devInfo ++ 00*5)
|
|
16
|
+
*
|
|
17
|
+
* data frame counter(4, big endian) | GCM{ 20-byte Govee frame }
|
|
18
|
+
* nonce = txIvKey(8) ++ counter(4)
|
|
19
|
+
* AAD = counter(4)
|
|
20
|
+
* key = deviceKey
|
|
21
|
+
* tag = 12 bytes appended
|
|
22
|
+
*
|
|
23
|
+
* devInfo is the ASCII SKU (5 bytes, e.g. "H3001") followed by the MAC address
|
|
24
|
+
* in reverse byte order, so the per-device key needs no cloud lookup.
|
|
25
|
+
*
|
|
26
|
+
* Counters are per-direction, start at 1, and increment by one per frame. They
|
|
27
|
+
* reset on every connection, so a handshake is required per connection.
|
|
28
|
+
*
|
|
29
|
+
* No new dependencies: node:crypto provides aes-128-gcm and aes-128-ecb.
|
|
30
|
+
*/
|
|
31
|
+
import { Buffer } from 'node:buffer'
|
|
32
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
33
|
+
|
|
34
|
+
// Application-wide constants, recovered from the Govee Home app's string
|
|
35
|
+
// resources (LibTools KEY_COMMUNICATION_X / _Y).
|
|
36
|
+
const KEY_HANDSHAKE = Buffer.from('fc03783c7c42cb83e202a1643648aff6', 'hex')
|
|
37
|
+
const KEY_DEVICE = Buffer.from('ae028b630bae6ecc4bff1b249e22f955', 'hex')
|
|
38
|
+
|
|
39
|
+
const TAG_LEN = 12 // 96-bit tag; the app falls back to 128-bit
|
|
40
|
+
const HDR = { MAGIC0: 0xE7, MAGIC1: 0x11, SUB_REQUEST: 0x01, STATUS_OK: 0x00 }
|
|
41
|
+
|
|
42
|
+
/** True if `data` is an e711 handshake frame rather than a data frame. */
|
|
43
|
+
export function isHandshakeFrame(data) {
|
|
44
|
+
return data.length >= 3 && data[0] === HDR.MAGIC0 && data[1] === HDR.MAGIC1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function gcmSeal(key, iv, aad, plaintext) {
|
|
48
|
+
const c = createCipheriv('aes-128-gcm', key, iv, { authTagLength: TAG_LEN })
|
|
49
|
+
c.setAAD(aad)
|
|
50
|
+
return Buffer.concat([c.update(plaintext), c.final(), c.getAuthTag()])
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function gcmOpen(key, iv, blob, aad) {
|
|
54
|
+
const d = createDecipheriv('aes-128-gcm', key, iv, { authTagLength: TAG_LEN })
|
|
55
|
+
d.setAAD(aad)
|
|
56
|
+
d.setAuthTag(blob.subarray(blob.length - TAG_LEN))
|
|
57
|
+
return Buffer.concat([d.update(blob.subarray(0, blob.length - TAG_LEN)), d.final()]) // throws on a bad tag
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build the handshake to write first on every new connection.
|
|
62
|
+
* Returns { frame, txIvKey } -- keep txIvKey, it seeds the nonce for writes.
|
|
63
|
+
*/
|
|
64
|
+
export function buildHandshake(txIvKey = randomBytes(8), iv = randomBytes(12)) {
|
|
65
|
+
const frame = Buffer.alloc(36)
|
|
66
|
+
frame[0] = HDR.MAGIC0
|
|
67
|
+
frame[1] = HDR.MAGIC1
|
|
68
|
+
frame[2] = HDR.SUB_REQUEST
|
|
69
|
+
iv.copy(frame, 3)
|
|
70
|
+
frame[15] = TAG_LEN
|
|
71
|
+
gcmSeal(KEY_HANDSHAKE, iv, frame.subarray(0, 16), txIvKey).copy(frame, 16)
|
|
72
|
+
return { frame, txIvKey }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse the light's handshake reply. Returns { rxIvKey, devInfo, sku, mac }.
|
|
77
|
+
* Throws if the GCM tag does not verify or the device reports an error status.
|
|
78
|
+
*/
|
|
79
|
+
export function parseHandshakeResponse(frame) {
|
|
80
|
+
if (!isHandshakeFrame(frame)) {
|
|
81
|
+
throw new Error(`not an e711 response: ${frame.subarray(0, 4).toString('hex')}`)
|
|
82
|
+
}
|
|
83
|
+
if (frame[2] !== HDR.STATUS_OK) {
|
|
84
|
+
throw new Error(`handshake rejected, status 0x${frame[2].toString(16)}`)
|
|
85
|
+
}
|
|
86
|
+
// Note the AAD here is 15 bytes: the reply carries no tag-length byte.
|
|
87
|
+
const blob = gcmOpen(KEY_HANDSHAKE, frame.subarray(3, 15), frame.subarray(15), frame.subarray(0, 15))
|
|
88
|
+
if (blob.length !== 19) {
|
|
89
|
+
throw new Error(`expected a 19-byte handshake blob, got ${blob.length}`)
|
|
90
|
+
}
|
|
91
|
+
const devInfo = blob.subarray(8, 19)
|
|
92
|
+
return {
|
|
93
|
+
rxIvKey: blob.subarray(0, 8),
|
|
94
|
+
devInfo,
|
|
95
|
+
sku: devInfo.subarray(0, 5).toString('ascii'),
|
|
96
|
+
mac: Buffer.from(devInfo.subarray(5, 11)).reverse().toString('hex').replace(/(..)(?=.)/g, '$1:'),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** deviceKey = AES-128-ECB-encrypt(KEY_DEVICE, devInfo padded to 16 with zeros). */
|
|
101
|
+
function deriveDeviceKey(devInfo) {
|
|
102
|
+
const block = Buffer.alloc(16)
|
|
103
|
+
devInfo.copy(block)
|
|
104
|
+
const c = createCipheriv('aes-128-ecb', KEY_DEVICE, null)
|
|
105
|
+
c.setAutoPadding(false)
|
|
106
|
+
return Buffer.concat([c.update(block), c.final()])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Per-connection session, wrapping outgoing 20-byte frames. */
|
|
110
|
+
export class V2Session {
|
|
111
|
+
constructor({ txIvKey, devInfo }) {
|
|
112
|
+
this.txIvKey = txIvKey
|
|
113
|
+
this.deviceKey = deriveDeviceKey(devInfo)
|
|
114
|
+
this.txCounter = 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Wrap a plain 20-byte Govee frame for writing. */
|
|
118
|
+
seal(frame20, counter = this.txCounter) {
|
|
119
|
+
const ctr = Buffer.alloc(4)
|
|
120
|
+
ctr.writeUInt32BE(counter >>> 0, 0)
|
|
121
|
+
const out = Buffer.concat([
|
|
122
|
+
ctr,
|
|
123
|
+
gcmSeal(this.deviceKey, Buffer.concat([this.txIvKey, ctr]), ctr, frame20),
|
|
124
|
+
])
|
|
125
|
+
if (counter === this.txCounter) {
|
|
126
|
+
this.txCounter += 1
|
|
127
|
+
}
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -57,6 +57,9 @@ export const UUID = {
|
|
|
57
57
|
SERVICE: '00010203-0405-0607-0809-0a0b0c0d1910',
|
|
58
58
|
WRITE_DEFAULT: '000102030405060708090a0b0c0d2b11',
|
|
59
59
|
WRITE_ALT: '000102030405060708090a0b0c0d2b10', // H615B
|
|
60
|
+
// The same attribute as WRITE_ALT, named for the role it plays on encrypted
|
|
61
|
+
// models: they answer the key handshake here.
|
|
62
|
+
NOTIFY: '000102030405060708090a0b0c0d2b10',
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
// Total BLE packet size
|
|
@@ -70,6 +73,11 @@ export const BLE_STAGE_TIMEOUTS = {
|
|
|
70
73
|
powerOn: 5000,
|
|
71
74
|
connect: 10000,
|
|
72
75
|
write: 5000,
|
|
76
|
+
// Encrypted models exchange a session key before the first real command (see
|
|
77
|
+
// ble-crypto.js). Captures of the official app show the light replying in
|
|
78
|
+
// about 30ms, so this is a generous ceiling rather than an expected cost.
|
|
79
|
+
// Models that need no handshake never spend any of it.
|
|
80
|
+
handshake: 5000,
|
|
73
81
|
settle: 100, // let the controller transmit before disconnecting
|
|
74
82
|
|
|
75
83
|
// Discovering services, and the disconnect on the way out, have no timeout of
|
|
@@ -54,6 +54,21 @@ const modelOverrides = {
|
|
|
54
54
|
// H615B uses alternate BLE write characteristic UUID
|
|
55
55
|
H615B: { bleWriteUuid: UUID.WRITE_ALT },
|
|
56
56
|
|
|
57
|
+
// Bluetooth-only solar string lights (#1328). Every frame is wrapped in
|
|
58
|
+
// AES-128-GCM, so this model needs the key handshake in ble-crypto.js before
|
|
59
|
+
// any command is accepted. Its colour and brightness formats were read off a
|
|
60
|
+
// capture of the official app:
|
|
61
|
+
// colour 33 05 15 01 RR GG BB 00 00 00 00 00 FF (then zero padding)
|
|
62
|
+
// brightness 33 04 <0-100> e.g. 0x1e for 30%, 0x64 for 100%
|
|
63
|
+
// Writes are unacknowledged, so without the wrapper the light silently
|
|
64
|
+
// ignores everything and the plugin cannot tell.
|
|
65
|
+
H3001: {
|
|
66
|
+
bleEncryption: 'v2',
|
|
67
|
+
bleColorCmd: COLOR_SUB.RGB_EXTENDED,
|
|
68
|
+
bleColorCmdSuffix: [0x00, 0x00, 0x00, 0x00, 0x00, 0xFF],
|
|
69
|
+
bleBrightnessScale: 0x64,
|
|
70
|
+
},
|
|
71
|
+
|
|
57
72
|
// H6121 requires cmdVersion 1 for status requests
|
|
58
73
|
H6121: { awsStatusCmdVersion: 1 },
|
|
59
74
|
|
|
@@ -116,6 +131,9 @@ const defaults = {
|
|
|
116
131
|
awsBrightnessNoScale: false,
|
|
117
132
|
readsSwingStatus: true,
|
|
118
133
|
iceSizeAscending: false,
|
|
134
|
+
// false for the great majority of models, which take the plain 20-byte frame.
|
|
135
|
+
// 'v2' selects the AES-128-GCM transport in ble-crypto.js.
|
|
136
|
+
bleEncryption: false,
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
export function getDeviceCapabilities(model, firmware) {
|
package/package.json
CHANGED