@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 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
 
@@ -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, finalBuffer, WRITE_TIMEOUT)
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
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-govee",
3
3
  "alias": "Govee",
4
4
  "type": "module",
5
- "version": "11.32.2-beta.25",
5
+ "version": "11.32.2-beta.27",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",