@homebridge-plugins/homebridge-govee 11.39.2-beta.9 → 11.40.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
- ## v11.39.2 (Pending Release)
5
+ ## v11.40.0 (2026-11-14)
6
6
 
7
7
  ### Changed
8
8
 
@@ -20,7 +20,6 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
20
20
  - fix(leak): stop the h5059 and h5830 showing an old leak, the app reads their probes (#1381)
21
21
  - fix(openapi): clear a leak when the sensor reports it is dry (#1381)
22
22
  - fix(aws): start aws without waiting for the openapi live connection, it can retry for ever (#1383)
23
- - chore(deps): dependency updates
24
23
  - fix(lights): keep adaptive lighting on after a restart when it ran while the light was off
25
24
  - fix(http): log in even when the tap-to-run server is down
26
25
  - fix(aws): give each start its own aws connection id, so two instances on one account both work
@@ -30,6 +29,33 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
30
29
  - fix(lights): keep the h6022 and h60a1 to the colour temperatures they can show
31
30
  - feat(humidifiers): show when the water tank has run dry, when govee's api reports it (#1294)
32
31
  - fix(h1310): stop the fan light throwing on every switch when there is no api key (#1384)
32
+ - fix(h713b): put the heater back on the handler its controls worked on (#1386)
33
+ - fix(aws): apply a light's aws brightness scale to 254 setting, which was ignored
34
+ - fix(ble): on a mac, point a bluetooth permission error at the privacy setting, not setcap
35
+ - fix(sockets): show a socket as a heater again, it failed with "not a constructor" (#1388)
36
+ - fix(valves): turn a valve off after its duration when it was on before a restart (#1387)
37
+ - fix(ble): connect to bluetooth-only lights on linux (@ldtnz)
38
+ - fix(http): fetch tap-to-runs with the account token (@StefanRiese)
39
+ - fix(ttr): keep the account topic out of the tap-to-run debug line (@StefanRiese)
40
+ - fix(ttr): read scene codes from govee's preset tap-to-runs (@StefanRiese)
41
+ - fix: read the h7142's humidity, which showed 0% (#1294)
42
+ - fix(openapi): keep a toggle's state when govee reports it as empty (#1395)
43
+ - feat: show the h7107's room temperature (#1394)
44
+ - fix: lead the h7107's tile with the fan
45
+ - fix(scenes): cap the scene selector at 97 scenes, so lights with more still show it (#1362)
46
+ - fix(h7107): ask the fan for its status every minute, so its temperature stays current (#1394)
47
+ - fix: treat the h600c and h6008 bulbs as matter models (#1399) (@R-STR)
48
+ - fix(lights): switch a light off when home sets it to 0% (#1397)
49
+ - feat(h60b2): control each head of the tree lamp as its own light (#1395) (@ethanjnrg)
50
+ - fix(lights): name each zone light in the home app (@ethanjnrg)
51
+ - fix(lights): follow a zone light's saturation, as the main light does (@ethanjnrg)
52
+ - fix(lights): send a zone light's white again after a colour (@ethanjnrg)
53
+ - fix(lights): ask for a zone light's state after a whole-light change (@ethanjnrg)
54
+ - feat(h60b2): set a head's white as kelvin, on its white leds (@ethanjnrg)
55
+ - fix(lights): show a zone light's colour in home when it changes elsewhere (@ethanjnrg)
56
+ - feat(lights): switch a sync box's hdmi input from home, when govee's api lists it (#1289)
57
+ - ci: publish releases with node.js v26, the current lts
58
+ - chore(deps): dependency updates
33
59
 
34
60
  ## v11.39.1 (2026-10-18)
35
61
 
@@ -420,7 +420,7 @@ export default class BLEConnection {
420
420
  return await Promise.race([
421
421
  process.platform === 'darwin'
422
422
  ? this.connectByNameDarwin(address, timeout)
423
- : btClient.connectAsync(address),
423
+ : this.connectAfterScan(address, timeout),
424
424
  new Promise((_, reject) => {
425
425
  timeoutId = setTimeout(() => reject(new Error('Connection timeout')), timeout)
426
426
  }),
@@ -434,6 +434,65 @@ export default class BLEConnection {
434
434
  // never resolve. Govee devices advertise a local name whose suffix is the last
435
435
  // two bytes of their MAC (e.g. Govee_H617A_325A for ...:32:5A), so scan and
436
436
  // match on that instead, then connect to the discovered peripheral directly.
437
+ // Linux: noble only sends LE Create Connection for a peripheral it has seen
438
+ // in a scan since the last reset, so connecting by address alone never
439
+ // reaches the controller. Wait for the adapter after the reset in
440
+ // updateDevice(), scan until the device advertises, then connect to it.
441
+ async connectAfterScan(address, timeout) {
442
+ const wanted = address.toLowerCase()
443
+ await btClient.waitForPoweredOnAsync(5000).catch(() => {})
444
+ let peripheral
445
+ try {
446
+ peripheral = await this.scanFor(wanted, 2500)
447
+ } catch {
448
+ // Some adapters (seen on a Realtek RTL8821C) stop delivering advertising
449
+ // reports after an LE connection, even though scanning is reported as
450
+ // enabled. btClient.reset() is a no-op on the hci-socket bindings, so
451
+ // send a real HCI reset and scan once more.
452
+ this.log.debug('[BLE] %s not seen, resetting the adapter and scanning again.', address)
453
+ btClient._bindings?._hci?.reset?.()
454
+ await new Promise(r => setTimeout(r, 1000))
455
+ await btClient.waitForPoweredOnAsync(2000).catch(() => {})
456
+ peripheral = await this.scanFor(wanted, Math.max(timeout - 6000, 3000))
457
+ }
458
+ // With a weak signal the link can drop with 0x3e (connection failed to be
459
+ // established) a few hundred ms after LE Connection Complete. Give it a
460
+ // moment to settle and try again if it did not hold.
461
+ for (let attempt = 1; ; attempt += 1) {
462
+ await peripheral.connectAsync()
463
+ await new Promise(r => setTimeout(r, 500))
464
+ if (peripheral.state === 'connected' || attempt >= 3) {
465
+ return peripheral
466
+ }
467
+ this.log.debug('[BLE] %s dropped right after connecting, retrying (%s/3).', address, attempt + 1)
468
+ }
469
+ }
470
+
471
+ scanFor(wanted, scanTimeout) {
472
+ return new Promise((resolve, reject) => {
473
+ let done = false
474
+ let scanTimeoutId
475
+ function onDiscover(p) {
476
+ if (p.address?.toLowerCase() === wanted) {
477
+ finish(resolve, p)
478
+ }
479
+ }
480
+ function finish(fn, value) {
481
+ if (done) {
482
+ return
483
+ }
484
+ done = true
485
+ clearTimeout(scanTimeoutId)
486
+ btClient.removeListener('discover', onDiscover)
487
+ btClient.stopScanningAsync().catch(() => {})
488
+ fn(value)
489
+ }
490
+ btClient.on('discover', onDiscover)
491
+ btClient.startScanningAsync([], true).catch(err => finish(reject, err))
492
+ scanTimeoutId = setTimeout(() => finish(reject, new Error(`no advertisement from ${wanted} during scan`)), scanTimeout)
493
+ })
494
+ }
495
+
437
496
  async connectByNameDarwin(address, timeout) {
438
497
  const suffix = address.replace(/:/g, '').slice(-4).toLowerCase()
439
498
  this.darwinPeripheralCache = this.darwinPeripheralCache ?? new Map()
@@ -16,7 +16,6 @@ export default class {
16
16
  this.log = platform.log
17
17
  this.password = platform.config.password
18
18
  this.token = platform.accountToken
19
- this.tokenTTR = platform.accountTokenTTR
20
19
  this.username = platform.config.username
21
20
  this.code = platform.config.code
22
21
 
@@ -110,18 +109,6 @@ export default class {
110
109
  // Make the token available in other functions
111
110
  this.token = res.data.client.token
112
111
 
113
- // Also grab an access token specifically for the get-tap-to-run endpoint.
114
- // It comes from a different server to the account login, and tap-to-runs
115
- // are optional - the token is fetched again when one is next needed. So a
116
- // failure here must not stop the login, or one server being down takes
117
- // every cloud device with it
118
- try {
119
- await this.loginTTR()
120
- } catch (err) {
121
- this.tokenTTR = undefined
122
- this.log.debug('[HTTP] %s %s.', platformLang.ttrLoginFail, parseError(err))
123
- }
124
-
125
112
  // Mark this request complete if in debug mode
126
113
  this.log.debug('[HTTP] %s.', platformLang.loginSuccess)
127
114
 
@@ -148,7 +135,6 @@ export default class {
148
135
  iot: iotRes.data.data.p12,
149
136
  iotPass: iotRes.data.data.p12Pass,
150
137
  token: res.data.client.token,
151
- tokenTTR: this.tokenTTR,
152
138
  topic: res.data.client.topic,
153
139
  }
154
140
  } catch (err) {
@@ -166,24 +152,6 @@ export default class {
166
152
  }
167
153
  }
168
154
 
169
- async loginTTR() {
170
- // The tap-to-run endpoint uses a separate, shorter-lived token obtained from
171
- // a different login endpoint to the main Govee account token. This is split
172
- // out so it can be refreshed on its own without a full account re-login.
173
- const ttrRes = await axios({
174
- url: 'https://community-api.govee.com/os/v1/login',
175
- method: 'post',
176
- data: {
177
- email: this.username,
178
- password: this.password,
179
- },
180
- timeout: 30000,
181
- })
182
-
183
- this.tokenTTR = ttrRes.data?.data?.token
184
- return this.tokenTTR
185
- }
186
-
187
155
  async logout() {
188
156
  try {
189
157
  await axios({
@@ -267,15 +235,12 @@ export default class {
267
235
  }
268
236
  }
269
237
 
270
- async getTapToRuns(isRetry = false) {
271
- // The TTR token is separate to the main account token and shorter-lived. If
272
- // we don't have a usable one (eg. restored from an old cache where it was
273
- // never saved, saved as the literal string 'undefined', or expired) then
274
- // fetch a fresh one before continuing. This lets a user with a valid main
275
- // token but a bad TTR token self-heal without a full account re-login.
276
- if (!this.tokenTTR || this.tokenTTR === 'undefined') {
277
- await this.loginTTR()
278
- this.tokenTTRRefreshed = true
238
+ async getTapToRuns() {
239
+ // Make sure we do have the account token. This endpoint used to take a
240
+ // separate token from community-api.govee.com, but Govee now rejects that
241
+ // one ('authorization token is invalid') and accepts the account token.
242
+ if (!this.token) {
243
+ throw new Error(platformLang.noTokenExists)
279
244
  }
280
245
 
281
246
  // Build and send the request
@@ -283,7 +248,7 @@ export default class {
283
248
  url: 'https://app2.govee.com/bff-app/v1/exec-plat/home',
284
249
  method: 'get',
285
250
  headers: {
286
- 'Authorization': `Bearer ${this.tokenTTR}`,
251
+ 'Authorization': `Bearer ${this.token}`,
287
252
  'appVersion': this.appVersion,
288
253
  'clientId': this.clientId,
289
254
  'clientType': 1,
@@ -296,14 +261,7 @@ export default class {
296
261
 
297
262
  // Check to see we got a response
298
263
  if (!res?.data?.data?.components) {
299
- // The token may have expired since we obtained it - try one fresh TTR
300
- // login and retry once before giving up
301
- if (!isRetry) {
302
- await this.loginTTR()
303
- this.tokenTTRRefreshed = true
304
- return this.getTapToRuns(true)
305
- }
306
- throw new Error('not a valid response')
264
+ throw new Error(`not a valid response (${res.data?.message || `HTTP ${res.status}`})`)
307
265
  }
308
266
 
309
267
  return res.data.data.components
@@ -107,6 +107,15 @@ function getCapabilitiesFromPayload(data) {
107
107
  || []
108
108
  }
109
109
 
110
+ /**
111
+ * Govee sometimes answers with an empty value for a toggle it does not know the
112
+ * state of (#1395). Read as a number that is 0, so it would show the toggle as
113
+ * off - leave the last known state alone instead.
114
+ */
115
+ function hasToggleValue(value) {
116
+ return value !== undefined && value !== null && value !== ''
117
+ }
118
+
110
119
  function getCapabilityValue(capability) {
111
120
  if (capability?.state && Object.hasOwn(capability.state, 'value')) {
112
121
  return capability.state.value
@@ -176,12 +185,18 @@ export function normalizeMqttMessage(message) {
176
185
  case 'humidity':
177
186
  normalized.targetHumidity = Number(value)
178
187
  break
188
+ case 'hdmiSource':
189
+ // 1 to 4 on the sync boxes, and nothing usable before they report one
190
+ if (Number.isInteger(Number(value)) && Number(value) > 0) {
191
+ normalized.hdmiSource = Number(value)
192
+ }
193
+ break
179
194
  case 'targetTemperature':
180
195
  case 'sliderTemperature':
181
196
  normalized.targetTemperature = value
182
197
  break
183
198
  default:
184
- if (capability?.instance?.endsWith('Toggle')) {
199
+ if (capability?.instance?.endsWith('Toggle') && hasToggleValue(value)) {
185
200
  normalized.toggles = normalized.toggles || {}
186
201
  normalized.toggles[capability.instance] = Number(value) === 1
187
202
  }
@@ -496,12 +511,18 @@ export default class {
496
511
  case 'humidity':
497
512
  normalized.targetHumidity = Number(value)
498
513
  break
514
+ case 'hdmiSource':
515
+ // 1 to 4 on the sync boxes, and nothing usable before they report one
516
+ if (Number.isInteger(Number(value)) && Number(value) > 0) {
517
+ normalized.hdmiSource = Number(value)
518
+ }
519
+ break
499
520
  case 'targetTemperature':
500
521
  case 'sliderTemperature':
501
522
  normalized.targetTemperature = value
502
523
  break
503
524
  default:
504
- if (capability?.instance?.endsWith('Toggle')) {
525
+ if (capability?.instance?.endsWith('Toggle') && hasToggleValue(value)) {
505
526
  normalized.toggles = normalized.toggles || {}
506
527
  normalized.toggles[capability.instance] = Number(value) === 1
507
528
  }
@@ -59,6 +59,11 @@ export default class extends GoveeDevice {
59
59
  // Add the fan service for the fan if it doesn't already exist
60
60
  this.service = this.accessory.getService(this.hapServ.Fanv2) || this.accessory.addService(this.hapServ.Fanv2)
61
61
 
62
+ // The fan is what its tile should lead with. It also carries a night light
63
+ // and a temperature sensor, and without saying so the Home app picks for
64
+ // itself and can land on one of those instead
65
+ this.service.setPrimaryService(true)
66
+
62
67
  // Add the set handler to the fan on/off characteristic
63
68
  this.service
64
69
  .getCharacteristic(this.hapChar.Active)
@@ -139,6 +144,14 @@ export default class extends GoveeDevice {
139
144
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
140
145
  }
141
146
 
147
+ // The fan's own room temperature sensor. The service is only added once the
148
+ // fan has sent a reading (see updateCurrentTemperature), so a fan that never
149
+ // reports one is not left with a tile stuck at its starting value
150
+ this.tempService = this.accessory.getService(this.hapServ.TemperatureSensor)
151
+ this.cacheTemp = this.tempService
152
+ ? this.tempService.getCharacteristic(this.hapChar.CurrentTemperature).value
153
+ : undefined
154
+
142
155
  // Output the customised options to the log
143
156
  const opts = JSON.stringify({
144
157
  hideLight: this.hideLight,
@@ -412,6 +425,35 @@ export default class extends GoveeDevice {
412
425
  }
413
426
  }
414
427
 
428
+ /**
429
+ * Show a reading from the fan's own temperature sensor.
430
+ *
431
+ * The reading holds the temperature in tenths of a degree FAHRENHEIT in its
432
+ * thousands, with bit 23 as the sign, so 0x0aad98 (699800) is 69.9°F, or
433
+ * 21.1°C. The Govee app reads this model's own sensor as Fahrenheit whatever
434
+ * the app's unit is set to (ThermometerSensorInfo.parseInfo, device type 297).
435
+ *
436
+ * @param {number} reading the 3-byte reading from an `aa 10` status frame
437
+ */
438
+ updateCurrentTemperature(reading) {
439
+ const isNegative = (reading & 0x800000) !== 0
440
+ const size = Math.floor((reading & 0x7FFFFF) / 1000)
441
+ const tenthsF = isNegative
442
+ ? -size
443
+ : size
444
+ const newTemp = Math.round(((tenthsF / 10 - 32) / 1.8) * 10) / 10
445
+
446
+ if (!this.tempService) {
447
+ this.tempService = this.accessory.addService(this.hapServ.TemperatureSensor)
448
+ }
449
+
450
+ if (newTemp !== this.cacheTemp) {
451
+ this.cacheTemp = newTemp
452
+ this.tempService.updateCharacteristic(this.hapChar.CurrentTemperature, newTemp)
453
+ this.accessory.log(`${platformLang.curTemp} [${this.cacheTemp}°C]`)
454
+ }
455
+ }
456
+
415
457
  externalUpdate(params) {
416
458
  // Update the active characteristic
417
459
  if (params.state && params.state !== this.cacheState) {
@@ -467,6 +509,18 @@ export default class extends GoveeDevice {
467
509
  return
468
510
  }
469
511
 
512
+ // Room temperature: aa 10 <flags> <3-byte reading>. Flag bit 7 marks a
513
+ // reading from the fan's own sensor (bit 0 is a linked thermometer, which
514
+ // is read differently), and 0xffffff means no reading
515
+ if (getTwoItemPosition(hexParts, 2) === '10') {
516
+ const flags = hexToDecimal(getTwoItemPosition(hexParts, 3))
517
+ const reading = hexToDecimal(`${getTwoItemPosition(hexParts, 4)}${getTwoItemPosition(hexParts, 5)}${getTwoItemPosition(hexParts, 6)}`)
518
+ if ((flags & 0x80) && reading !== 0xFFFFFF) {
519
+ this.updateCurrentTemperature(reading)
520
+ }
521
+ return
522
+ }
523
+
470
524
  const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
471
525
 
472
526
  switch (deviceFunction) {
@@ -428,6 +428,30 @@ export default class extends GoveeDevice {
428
428
  return
429
429
  }
430
430
 
431
+ // Humidity: aa 10 <flags> <3-byte reading>. Flag bit 7 means the reading is
432
+ // from the humidifier's own sensor, bit 0 from a linked thermometer. The
433
+ // reading holds the temperature in its thousands and the humidity in tenths
434
+ // of a percent below that, so 0x000212 (530) is 53.0%, and 0xffffff is none
435
+ if (getTwoItemPosition(hexParts, 2) === '10') {
436
+ const flags = hexToDecimal(getTwoItemPosition(hexParts, 3))
437
+ const reading = hexToDecimal(`${getTwoItemPosition(hexParts, 4)}${getTwoItemPosition(hexParts, 5)}${getTwoItemPosition(hexParts, 6)}`)
438
+ if ((flags & 0x81) && reading !== 0xFFFFFF) {
439
+ const newHumiHKValue = Math.round(((reading & 0x7FFFFF) % 1000) / 10)
440
+ if (newHumiHKValue !== this.cacheHumi) {
441
+ this.cacheHumi = newHumiHKValue
442
+ this.humiService.updateCharacteristic(this.hapChar.CurrentRelativeHumidity, this.cacheHumi)
443
+ this.accessory.log(`${platformLang.curHumi} [${this.cacheHumi}%]`)
444
+ }
445
+ }
446
+ return
447
+ }
448
+
449
+ // Reports this device sends that Home has no use for: a linked thermometer (08),
450
+ // timers (12), cleaning reminders (17), humidity calibration (1e) and charts (30)
451
+ if (['08', '12', '17', '1e', '30'].includes(getTwoItemPosition(hexParts, 2))) {
452
+ return
453
+ }
454
+
431
455
  const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
432
456
 
433
457
  switch (deviceFunction) {
@@ -471,20 +495,6 @@ export default class extends GoveeDevice {
471
495
  }
472
496
  break
473
497
  }
474
- case '1001': { // sometimes humidity value
475
- const humiCheck = getTwoItemPosition(hexParts, 4)
476
- if (humiCheck === '00') {
477
- const newHumiHex = `${getTwoItemPosition(hexParts, 5)}${getTwoItemPosition(hexParts, 6)}`
478
- const newHumiDec = Math.round(hexToDecimal(newHumiHex)) / 10 // eg 55.5%
479
- const newHumiHKValue = Math.round(newHumiDec) // eg 56%
480
- if (newHumiHKValue !== this.cacheHumi) {
481
- this.cacheHumi = newHumiHKValue
482
- this.humiService.updateCharacteristic(this.hapChar.CurrentRelativeHumidity, this.cacheHumi)
483
- this.accessory.log(`${platformLang.curHumi} [${this.cacheHumi}%]`)
484
- }
485
- }
486
- break
487
- }
488
498
  case '1800': // display off
489
499
  case '1801': { // display on
490
500
  const newDisplay = deviceFunction === '1801'