@homebridge-plugins/homebridge-govee 11.34.2-beta.4 → 11.34.2-beta.6

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
@@ -13,10 +13,13 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
13
13
  - fix: send a colour change when only the saturation moves, on every night light
14
14
  - fix: listen for saturation on the main light too, not only hue
15
15
  - fix: accept 20-byte H5106 status frames (#1345) (@danieloleary)
16
+ - fix: stop rescaling the H1250's brightness, since it already counts to 100 over AWS (#1347) (@Principe1218)
17
+ - feat: give the H1250's zones independent color and brightness (#1346) (@Principe1218)
16
18
  - fix: stop a lan scan that cannot reach the network from crashing the bridge
17
19
  - fix: apply the openapi disable and refresh settings instead of asking for them to be removed
18
20
  - fix: say once when a bluetooth reading arrives for an unknown device, not on every broadcast
19
21
  - fix: reject a refresh time so large it would make the plugin poll every millisecond
22
+ - fix(H5054): don't let a slow leak warning block other sensors
20
23
 
21
24
  ## v11.34.1 (2026-08-06)
22
25
 
@@ -43,6 +43,75 @@ function readZoneFrame(commands = []) {
43
43
  return zones
44
44
  }
45
45
 
46
+ /**
47
+ * The brightness and color a two-zone light reports for every one of its
48
+ * light segments, as `aa a5 <page>` followed by four groups of
49
+ * `<level> <r> <g> <b>`.
50
+ *
51
+ * Pages run from 01 and each covers four light segments, so page N describes
52
+ * segments (N-1)*4 upward. A light whose segment count is not a multiple of
53
+ * four pads the last page with zeroes, and those are skipped rather than
54
+ * reported as black segments that do not exist (#1333).
55
+ *
56
+ * @param {string[]} [commands] the base64 frames from the payload
57
+ * @returns {object} what each light segment reported, keyed by segment number
58
+ */
59
+ function readLightSegmentFrames(commands = []) {
60
+ const lightSegments = {}
61
+ commands.forEach((command) => {
62
+ const hexParts = hexToTwoItems(base64ToHex(command))
63
+ if (`${getTwoItemPosition(hexParts, 1)}${getTwoItemPosition(hexParts, 2)}` !== 'aaa5') {
64
+ return
65
+ }
66
+ const page = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
67
+ for (let group = 0; group < 4; group += 1) {
68
+ const at = 4 + (group * 4)
69
+ const level = Number.parseInt(getTwoItemPosition(hexParts, at), 16)
70
+ const r = Number.parseInt(getTwoItemPosition(hexParts, at + 1), 16)
71
+ const g = Number.parseInt(getTwoItemPosition(hexParts, at + 2), 16)
72
+ const b = Number.parseInt(getTwoItemPosition(hexParts, at + 3), 16)
73
+ if (level === 0 && r === 0 && g === 0 && b === 0) {
74
+ continue
75
+ }
76
+ lightSegments[((page - 1) * 4) + group] = { level, r, g, b }
77
+ }
78
+ })
79
+ return lightSegments
80
+ }
81
+
82
+ /**
83
+ * The two zones a light of this kind has. Fixed, unlike which light segments
84
+ * each one owns, which varies by model - see `zoneLightModels` in constants.js.
85
+ */
86
+ const ZONE_DEFS = [
87
+ { instance: 'mainLightToggle', zone: 'main', name: 'Main Light' },
88
+ { instance: 'backgroundLightToggle', zone: 'background', name: 'Background Light' },
89
+ ]
90
+
91
+ /**
92
+ * Real HAP's AdaptiveLightingController adds these three characteristics to
93
+ * the service it attaches to. When adaptive lighting has just been switched
94
+ * off, only these need to go - the whole Lightbulb service used to be thrown
95
+ * away and rebuilt here, which does hide the icon but presents Home with what
96
+ * looks like a different light, and an owner can lose that light's room and
97
+ * its automations as a result.
98
+ *
99
+ * @param {object} service the HAP service to remove the characteristics from
100
+ * @param {object} hapChar the platform's Characteristic lookup
101
+ */
102
+ function clearAdaptiveLightingCharacteristics(service, hapChar) {
103
+ [
104
+ 'SupportedCharacteristicValueTransitionConfiguration',
105
+ 'CharacteristicValueTransitionControl',
106
+ 'CharacteristicValueActiveTransitionCount',
107
+ ].forEach((charName) => {
108
+ const char = hapChar[charName]
109
+ if (char && service.testCharacteristic(char)) {
110
+ service.removeCharacteristic(service.getCharacteristic(char))
111
+ }
112
+ })
113
+ }
114
+
46
115
  export default class extends GoveeDevice {
47
116
  constructor(platform, accessory) {
48
117
  super(platform, accessory)
@@ -78,16 +147,7 @@ export default class extends GoveeDevice {
78
147
  // the icon but presents Home with what looks like a different light, and an
79
148
  // owner can lose that light's room and its automations as a result.
80
149
  if ((this.colourSafeMode || this.alShift === -1) && this.accessory.context.adaptiveLighting) {
81
- [
82
- 'SupportedCharacteristicValueTransitionConfiguration',
83
- 'CharacteristicValueTransitionControl',
84
- 'CharacteristicValueActiveTransitionCount',
85
- ].forEach((charName) => {
86
- const char = this.hapChar[charName]
87
- if (char && this.service.testCharacteristic(char)) {
88
- this.service.removeCharacteristic(this.service.getCharacteristic(char))
89
- }
90
- })
150
+ clearAdaptiveLightingCharacteristics(this.service, this.hapChar)
91
151
  this.accessory.context.adaptiveLighting = false
92
152
  }
93
153
 
@@ -247,41 +307,153 @@ export default class extends GoveeDevice {
247
307
 
248
308
  // Some lights (e.g. the H1270 Ceiling Light Ultra) have two independently
249
309
  // controllable zones - a main/bottom panel and a background/top light. Govee
250
- // exposes these as OpenAPI toggle capabilities, so add a switch for each zone
251
- // the device reports, controlled over the OpenAPI connection.
310
+ // exposes these as OpenAPI toggle capabilities, so add a lightbulb for each
311
+ // zone the device reports, controlled over the OpenAPI connection.
252
312
  //
253
313
  // The models in `zoneLightModels` advertise those same capabilities but the
254
314
  // cloud never delivers them, so they are driven by a raw frame over AWS
255
- // instead. That is the only difference - the tiles, their state and the way
256
- // a failure is undone are the same either way (#1333)
315
+ // instead - and only those models can address a zone's brightness and
316
+ // color, since the raw frame reaches light segments the OpenAPI toggle
317
+ // capabilities never could. Either way the tile itself, its state and how
318
+ // a failure is undone are the same (#1333)
257
319
  this.zoneServices = {}
258
320
  this.zoneCache = {}
259
- this.hasRawZones = platformConsts.zoneLightModels.includes(accessory.context.gvModel)
260
- const zoneToggleDefs = [
261
- { instance: 'mainLightToggle', zone: 'main', name: 'Main Light' },
262
- { instance: 'backgroundLightToggle', zone: 'background', name: 'Background Light' },
263
- ]
321
+ // "light segments" to avoid any confusion with the unrelated
322
+ // segmented/segmentedTwo/segmentedThree/segmentedFour config options
323
+ // elsewhere in this file, which name a captured scene or BLE code
324
+ this.zoneLightSegments = platformConsts.zoneLightModels[accessory.context.gvModel]
325
+ this.hasRawZones = Boolean(this.zoneLightSegments)
326
+ this.zoneBrightCache = {}
327
+ this.zoneColourCache = {}
328
+ this.zoneMiredCache = {}
329
+ this.zoneKelvinCache = {}
330
+ this.zoneUpdateKey = {}
331
+ this.zoneAlControllers = {}
264
332
  const openApiCaps = accessory.context.openApiCapabilities || {}
265
- zoneToggleDefs.forEach(({ instance, zone, name }) => {
266
- const existingService = this.accessory.getService(name)
333
+ ZONE_DEFS.forEach(({ instance, zone, name }) => {
334
+ // A real HAP Service has no `.type` - only `.UUID`, `.subtype` and
335
+ // `.displayName` - so getServiceById is the correct way to ask "does this
336
+ // zone's lightbulb already exist", matching by type AND subtype rather
337
+ // than by name. Falling back to a name lookup only when that comes up
338
+ // empty catches a service under this name that ISN'T this lightbulb,
339
+ // which can only be the tile's previous Switch, since HomeKit cannot
340
+ // change a service's type in place
341
+ const existingLightbulb = this.accessory.getServiceById(this.hapServ.Lightbulb, instance)
342
+ let existingService = existingLightbulb || this.accessory.getService(name)
267
343
 
268
344
  // The raw frame goes over AWS, which needs Govee account credentials
269
- // rather than just an API key
345
+ // rather than just an API key. For raw-zone models, also require this
346
+ // particular zone to have a measured light segment layout - a model
347
+ // whose config only defines one of its two zones should not offer a
348
+ // tile for the other one, since every raw-zone handler indexes
349
+ // zoneLightSegments[zone] and would throw at tap time on a real device
350
+ // rather than at startup
270
351
  const isControllable = this.hasRawZones
271
- ? accessory.context.useAwsControl
352
+ ? accessory.context.useAwsControl && Boolean(this.zoneLightSegments[zone])
272
353
  : accessory.context.useOpenApiControl && openApiCaps[instance]
273
354
 
274
355
  if (isControllable) {
356
+ // That name-matched fallback is stale (the old Switch) whenever it
357
+ // isn't the lightbulb itself - clear it out before the lightbulb can
358
+ // take the name, otherwise the tile is stuck with On and nothing
359
+ // else, and no amount of adding characteristics will fix it
360
+ if (existingService && existingService !== existingLightbulb) {
361
+ this.accessory.removeService(existingService)
362
+ existingService = undefined
363
+ }
364
+
275
365
  const service = existingService
276
- || this.accessory.addService(this.hapServ.Switch, name, instance)
366
+ || this.accessory.addService(this.hapServ.Lightbulb, name, instance)
277
367
  service
278
368
  .getCharacteristic(this.hapChar.On)
279
369
  .onSet(async value => this.internalZoneUpdate(instance, zone, name, value))
370
+
371
+ // Only the raw-frame models can address a zone's brightness and color.
372
+ // The models whose toggles work have on/off and nothing else, so giving
373
+ // them sliders would be a control that silently does nothing - which is
374
+ // the bug this whole path exists to avoid (#1333)
375
+ if (this.hasRawZones) {
376
+ // Adaptive lighting characteristics are added to a service by real
377
+ // HAP's AdaptiveLightingController when it attaches, not by this
378
+ // file, so they can only be sitting on a zone service left over from
379
+ // an earlier boot that had a controller here. Zones have no
380
+ // equivalent of `accessory.context.adaptiveLighting` above - that
381
+ // flag exists purely to remember, for the MAIN light, that this
382
+ // block already ran once so it does not repeat needlessly. A zone's
383
+ // controller is fully re-derived from colourSafeMode/alShift on
384
+ // every boot regardless (see below), so there is nothing to
385
+ // remember here: checking those two directly is enough to know
386
+ // whether this boot's controller state has changed from the last
387
+ if (this.colourSafeMode || this.alShift === -1) {
388
+ clearAdaptiveLightingCharacteristics(service, this.hapChar)
389
+ }
390
+
391
+ service
392
+ .getCharacteristic(this.hapChar.Brightness)
393
+ .setProps({ minStep: this.brightStep })
394
+ .onSet(async value => this.internalZoneBrightnessUpdate(instance, zone, name, value))
395
+ service
396
+ .getCharacteristic(this.hapChar.Hue)
397
+ .onSet(async value => this.internalZoneColourUpdate(instance, zone, name, value))
398
+ service.getCharacteristic(this.hapChar.Saturation)
399
+ this.zoneBrightCache[instance] = service.getCharacteristic(this.hapChar.Brightness).value
400
+
401
+ // Seed this now, not just on the first write or status report. An
402
+ // unseeded cache would make externalUpdate's "did this change
403
+ // elsewhere" check treat the very first report after every restart
404
+ // as an external change - switching adaptive lighting off before
405
+ // anything has actually changed
406
+ const seedHue = service.getCharacteristic(this.hapChar.Hue).value
407
+ const seedSat = service.getCharacteristic(this.hapChar.Saturation).value
408
+ const [seedR, seedG, seedB] = hs2rgb(seedHue, seedSat)
409
+ this.zoneColourCache[instance] = {
410
+ r: seedR,
411
+ g: seedG,
412
+ b: seedB,
413
+ hue: seedHue,
414
+ sat: seedSat,
415
+ }
416
+
417
+ // Adaptive lighting needs Brightness and ColorTemperature on the same
418
+ // service, so a zone that is to have it needs its own color
419
+ // temperature control. The zone only takes rgb on the wire, so a
420
+ // temperature is converted on the way out - the same thing the
421
+ // bluetooth path already does for every other light
422
+ if (this.colourSafeMode) {
423
+ if (service.testCharacteristic(this.hapChar.ColorTemperature)) {
424
+ service.removeCharacteristic(service.getCharacteristic(this.hapChar.ColorTemperature))
425
+ }
426
+ this.zoneMiredCache[instance] = 0
427
+ this.zoneKelvinCache[instance] = 0
428
+ } else {
429
+ service
430
+ .getCharacteristic(this.hapChar.ColorTemperature)
431
+ .onSet(async value => this.internalZoneCTUpdate(instance, zone, name, value))
432
+ this.zoneMiredCache[instance] = service.getCharacteristic(this.hapChar.ColorTemperature).value
433
+
434
+ // Seeded the same way as the mired cache above, so the first real
435
+ // adaptive lighting tick after a restart is compared against what
436
+ // was actually last sent, not against nothing
437
+ const seedMired = this.zoneMiredCache[instance]
438
+ const seedKelvin = Math.round(1000000 / seedMired / 100) * 100
439
+ this.zoneKelvinCache[instance] = Math.min(Math.max(seedKelvin, this.minKelvin), this.maxKelvin)
440
+
441
+ // One controller per zone. The id is derived from the service, so
442
+ // these sit alongside the main light's rather than clashing with it
443
+ if (this.alShift !== -1) {
444
+ this.zoneAlControllers[instance] = new platform.api.hap.AdaptiveLightingController(service, {
445
+ customTemperatureAdjustment: this.alShift,
446
+ })
447
+ this.accessory.configureController(this.zoneAlControllers[instance])
448
+ }
449
+ }
450
+ }
451
+
280
452
  this.zoneServices[instance] = service
281
453
  this.zoneCache[instance] = service.getCharacteristic(this.hapChar.On).value ? 1 : 0
282
454
  } else if (existingService) {
283
455
  // Capability no longer available (or the connection it needs is off) so
284
- // remove the switch
456
+ // remove the tile
285
457
  this.accessory.removeService(existingService)
286
458
  }
287
459
  })
@@ -392,6 +564,193 @@ export default class extends GoveeDevice {
392
564
  }
393
565
  }
394
566
 
567
+ /**
568
+ * Sets one zone's brightness in response to a HomeKit write.
569
+ *
570
+ * @param {string} instance the capability instance, eg 'mainLightToggle'
571
+ * @param {string} zone the key into `this.zoneLightSegments`, eg 'main'
572
+ * @param {string} name the tile's display name, for logging
573
+ * @param {number} value 0-100
574
+ */
575
+ async internalZoneBrightnessUpdate(instance, zone, name, value) {
576
+ const service = this.zoneServices[instance]
577
+ try {
578
+ // HomeKit writes once per position of the slider, so without this a single
579
+ // drag becomes a burst of frames. Same debounce the main light uses
580
+ const updateKey = generateRandomString(5)
581
+ this.zoneUpdateKey[instance] = updateKey
582
+ await sleep(350)
583
+ if (updateKey !== this.zoneUpdateKey[instance]) {
584
+ return
585
+ }
586
+
587
+ // Don't continue if the new value is the same as before
588
+ if (this.zoneBrightCache[instance] === value) {
589
+ return
590
+ }
591
+
592
+ await this.platform.sendDeviceUpdate(this.accessory, {
593
+ cmd: 'zoneBrightness',
594
+ lightSegments: this.zoneLightSegments[zone],
595
+ value,
596
+ })
597
+
598
+ this.zoneBrightCache[instance] = value
599
+ this.accessory.log(`[${name}] ${platformLang.curBright} [${value}%]`)
600
+ } catch (err) {
601
+ this.accessory.logWarn(`[${name}] ${platformLang.devNotUpdated} ${parseError(err)}`)
602
+
603
+ // Throw a 'no response' error and revert this after 2 seconds
604
+ setTimeout(() => {
605
+ if (service) {
606
+ service.updateCharacteristic(this.hapChar.Brightness, this.zoneBrightCache[instance])
607
+ }
608
+ }, 2000)
609
+ throw new this.hapErr(-70402)
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Sets one zone's color in response to a HomeKit hue write. Saturation is
615
+ * read from the tile itself, since HomeKit sends hue and saturation as
616
+ * separate writes.
617
+ *
618
+ * @param {string} instance the capability instance, eg 'mainLightToggle'
619
+ * @param {string} zone the key into `this.zoneLightSegments`, eg 'main'
620
+ * @param {string} name the tile's display name, for logging
621
+ * @param {number} value hue, 0-360
622
+ */
623
+ async internalZoneColourUpdate(instance, zone, name, value) {
624
+ const service = this.zoneServices[instance]
625
+ try {
626
+ // Same debounce as the main light's colour wheel
627
+ const updateKey = generateRandomString(5)
628
+ this.zoneUpdateKey[instance] = updateKey
629
+ await sleep(300)
630
+ if (updateKey !== this.zoneUpdateKey[instance]) {
631
+ return
632
+ }
633
+
634
+ // Dropping the cct to its lowest mimics native adaptive lighting, and
635
+ // HAP asks for it on any service carrying both hue/saturation and color
636
+ // temperature. The main light does the same
637
+ if (!this.colourSafeMode) {
638
+ service.updateCharacteristic(this.hapChar.ColorTemperature, 140)
639
+ }
640
+
641
+ // Hue and saturation arrive as separate writes, so the other half comes
642
+ // from this tile's own characteristic - not the main light's
643
+ const currentSat = service.getCharacteristic(this.hapChar.Saturation).value
644
+ const [r, g, b] = hs2rgb(value, currentSat)
645
+
646
+ // Don't continue if the new value is the same as before
647
+ const cached = this.zoneColourCache[instance]
648
+ if (cached && cached.r === r && cached.g === g && cached.b === b) {
649
+ return
650
+ }
651
+
652
+ await this.platform.sendDeviceUpdate(this.accessory, {
653
+ cmd: 'zoneColour',
654
+ lightSegments: this.zoneLightSegments[zone],
655
+ value: { r, g, b },
656
+ })
657
+
658
+ this.zoneColourCache[instance] = {
659
+ r,
660
+ g,
661
+ b,
662
+ hue: value,
663
+ sat: currentSat,
664
+ }
665
+ this.accessory.log(`[${name}] ${platformLang.curColour} [rgb ${r} ${g} ${b}]`)
666
+ } catch (err) {
667
+ this.accessory.logWarn(`[${name}] ${platformLang.devNotUpdated} ${parseError(err)}`)
668
+
669
+ // Throw a 'no response' error and revert this after 2 seconds
670
+ setTimeout(() => {
671
+ const previous = this.zoneColourCache[instance]
672
+ if (service && previous) {
673
+ service.updateCharacteristic(this.hapChar.Hue, previous.hue)
674
+ service.updateCharacteristic(this.hapChar.Saturation, previous.sat)
675
+ }
676
+ }, 2000)
677
+ throw new this.hapErr(-70402)
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Sets one zone's color temperature. Adaptive lighting calls this on its own
683
+ * schedule; a HomeKit write reaches it the same way.
684
+ *
685
+ * @param {string} instance the capability instance, eg 'mainLightToggle'
686
+ * @param {string} zone the key into `this.zoneLightSegments`, eg 'main'
687
+ * @param {string} name the tile's display name, for logging
688
+ * @param {number} value mireds
689
+ */
690
+ async internalZoneCTUpdate(instance, zone, name, value) {
691
+ const service = this.zoneServices[instance]
692
+ try {
693
+ // Adaptive lighting writes here every sixty seconds, so this is debounced
694
+ // exactly as the main light's color temperature is
695
+ const updateKey = generateRandomString(5)
696
+ this.zoneUpdateKey[instance] = updateKey
697
+ await sleep(300)
698
+ if (updateKey !== this.zoneUpdateKey[instance]) {
699
+ return
700
+ }
701
+
702
+ // Convert mired to kelvin to nearest 100, the same rounding the main
703
+ // light uses. Adaptive lighting nudges mired by fractional amounts far
704
+ // more often than the rounded kelvin actually changes, so comparing the
705
+ // rounded value below - not raw mired - is what keeps a real command
706
+ // from going out on almost every adaptive lighting tick
707
+ const kelvin = Math.round(1000000 / value / 100) * 100
708
+ const k = Math.min(Math.max(kelvin, this.minKelvin), this.maxKelvin)
709
+
710
+ // Don't continue if the rounded kelvin is the same as before
711
+ if (this.zoneKelvinCache[instance] === k) {
712
+ return
713
+ }
714
+
715
+ // Updating hue/sat to the corresponding values mimics native adaptive
716
+ // lighting - the main light's own color temperature handler does the same
717
+ const [hue, sat] = m2hs(value)
718
+ service.updateCharacteristic(this.hapChar.Hue, hue)
719
+ service.updateCharacteristic(this.hapChar.Saturation, sat)
720
+
721
+ // The zone frame carries rgb and nothing else, so the temperature is
722
+ // converted here - the same conversion the bluetooth path uses
723
+ const [r, g, b] = k2rgb(k)
724
+
725
+ await this.platform.sendDeviceUpdate(this.accessory, {
726
+ cmd: 'zoneColour',
727
+ lightSegments: this.zoneLightSegments[zone],
728
+ value: { r, g, b },
729
+ })
730
+
731
+ this.zoneKelvinCache[instance] = k
732
+ this.zoneMiredCache[instance] = value
733
+ this.zoneColourCache[instance] = {
734
+ r,
735
+ g,
736
+ b,
737
+ hue,
738
+ sat,
739
+ }
740
+ this.accessory.log(`[${name}] ${platformLang.curColour} [${k}K / ${value}M]`)
741
+ } catch (err) {
742
+ this.accessory.logWarn(`[${name}] ${platformLang.devNotUpdated} ${parseError(err)}`)
743
+
744
+ // Throw a 'no response' error and revert this after 2 seconds
745
+ setTimeout(() => {
746
+ if (service) {
747
+ service.updateCharacteristic(this.hapChar.ColorTemperature, this.zoneMiredCache[instance])
748
+ }
749
+ }, 2000)
750
+ throw new this.hapErr(-70402)
751
+ }
752
+ }
753
+
395
754
  async internalBrightnessUpdate(value) {
396
755
  try {
397
756
  // This acts like a debounce function when endlessly sliding the brightness scale
@@ -762,5 +1121,68 @@ export default class extends GoveeDevice {
762
1121
  }
763
1122
  })
764
1123
  }
1124
+
1125
+ // The same status carries every light segment's brightness and color, so a
1126
+ // change made in the Govee app, on a remote or at the wall reaches the
1127
+ // zone tiles too - not just our own writes echoing back
1128
+ if (this.hasRawZones) {
1129
+ const lightSegments = readLightSegmentFrames(params.commands)
1130
+ ZONE_DEFS.forEach(({ instance, zone, name }) => {
1131
+ const service = this.zoneServices[instance]
1132
+ if (!service) {
1133
+ return
1134
+ }
1135
+
1136
+ // Every light segment in a zone normally holds the same value. Where
1137
+ // they differ - someone has colored segments individually in the
1138
+ // Govee app - the first is what the tile shows, because HomeKit has
1139
+ // one value per characteristic and this zone has sixteen segments
1140
+ const first = this.zoneLightSegments[zone].find(index => lightSegments[index])
1141
+ const reported = first === undefined ? undefined : lightSegments[first]
1142
+ if (!reported) {
1143
+ return
1144
+ }
1145
+
1146
+ if (this.zoneBrightCache[instance] !== reported.level) {
1147
+ this.zoneBrightCache[instance] = reported.level
1148
+ service.updateCharacteristic(this.hapChar.Brightness, reported.level)
1149
+ this.accessory.log(`[${name}] ${platformLang.curBright} [${reported.level}%]`)
1150
+ }
1151
+
1152
+ const cached = this.zoneColourCache[instance]
1153
+ if (!cached || cached.r !== reported.r || cached.g !== reported.g || cached.b !== reported.b) {
1154
+ const [hue, sat] = rgb2hs(reported.r, reported.g, reported.b)
1155
+
1156
+ // A zone repeating our own command back can drift by a unit or two
1157
+ // through the conversions on the way out and back. Beyond that
1158
+ // drift, somebody else set this color
1159
+ const changedElsewhere = !cached
1160
+ || Math.abs(reported.r - cached.r) > 5
1161
+ || Math.abs(reported.g - cached.g) > 5
1162
+ || Math.abs(reported.b - cached.b) > 5
1163
+
1164
+ this.zoneColourCache[instance] = {
1165
+ r: reported.r,
1166
+ g: reported.g,
1167
+ b: reported.b,
1168
+ hue,
1169
+ sat,
1170
+ }
1171
+ service.updateCharacteristic(this.hapChar.Hue, hue)
1172
+ service.updateCharacteristic(this.hapChar.Saturation, sat)
1173
+ this.accessory.log(`[${name}] ${platformLang.curColour} [rgb ${reported.r} ${reported.g} ${reported.b}]`)
1174
+
1175
+ // Somebody chose this color in the Govee app, on a remote or at the
1176
+ // wall. Homebridge catches HomeKit's own writes, but nothing sees
1177
+ // these - so this zone's adaptive lighting would carry on and move
1178
+ // it back off that color within minutes (#1333)
1179
+ const controller = this.zoneAlControllers[instance]
1180
+ if (changedElsewhere && controller?.isAdaptiveLightingActive?.()) {
1181
+ controller.disableAdaptiveLighting()
1182
+ this.accessory.log(`[${name}] ${platformLang.alDisabled}`)
1183
+ }
1184
+ }
1185
+ })
1186
+ }
765
1187
  }
766
1188
  }
package/lib/platform.js CHANGED
@@ -408,11 +408,7 @@ export default class {
408
408
 
409
409
  // Set up the http client sync needed for leak and thermo sensor devices
410
410
  if (this.httpClient && httpSyncNeeded) {
411
- this.goveeHTTPSync()
412
- this.refreshHTTPInterval = setInterval(
413
- () => this.runPeriodicSync('HTTP', () => this.goveeHTTPSync()),
414
- this.config.httpRefreshTime * 1000,
415
- )
411
+ this.setupHTTPSync()
416
412
  }
417
413
 
418
414
  if (this.openApiClient && (openApiDevicesWereInitialised || httpDevicesWereInitialised)) {
@@ -787,6 +783,14 @@ export default class {
787
783
  }
788
784
  }
789
785
 
786
+ setupHTTPSync() {
787
+ this.runPeriodicSync('HTTP', () => this.goveeHTTPSync())
788
+ this.refreshHTTPInterval = setInterval(
789
+ () => this.runPeriodicSync('HTTP', () => this.goveeHTTPSync()),
790
+ this.config.httpRefreshTime * 1000,
791
+ )
792
+ }
793
+
790
794
  async goveeAWSSync(allDevices = false) {
791
795
  const pollList = allDevices ? awsDevices : awsDevicesToPoll
792
796
  if (pollList.length === 0) {
@@ -864,123 +868,129 @@ export default class {
864
868
  // Obtain a refreshed device list
865
869
  const devices = await this.httpClient.getDevices(true)
866
870
 
867
- // Filter those which are leak sensors
868
- for (const device1 of devices
869
- .filter(device => [...platformConsts.models.sensorLeak, ...platformConsts.models.sensorThermo, ...platformConsts.models.sensorMonitor].includes(device.sku))) {
870
- try {
871
- // Reformat the device id
872
- if (!device1.device.includes(':')) {
873
- // Eg converts abcd1234abcd1234 to AB:CD:12:34:AB:CD:12:34
874
- // For sensors with an add-on sensor like H5178
875
- // Eg converts abcd1234abcd1234_1 to AB:CD:12:34:AB:CD:12:34_1
876
- device1.device = device1.device.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase()
877
- }
871
+ // A stalled leak-warning request must not hold every water sensor behind
872
+ // it. Queue only those cloud requests so thermometer and monitor updates
873
+ // still arrive immediately, while a poll stays gentle on Govee's API.
874
+ const leakWarningQueue = new PQueue({ concurrency: 2 })
875
+ await Promise.all(devices
876
+ .filter(device => [...platformConsts.models.sensorLeak, ...platformConsts.models.sensorThermo, ...platformConsts.models.sensorMonitor].includes(device.sku))
877
+ .map(async (device1) => {
878
+ try {
879
+ // Reformat the device id
880
+ if (!device1.device.includes(':')) {
881
+ // Eg converts abcd1234abcd1234 to AB:CD:12:34:AB:CD:12:34
882
+ // For sensors with an add-on sensor like H5178
883
+ // Eg converts abcd1234abcd1234_1 to AB:CD:12:34:AB:CD:12:34_1
884
+ device1.device = device1.device.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase()
885
+ }
878
886
 
879
- // Generate the UIID from which we can match our Homebridge accessory
880
- const uiid = this.api.hap.uuid.generate(device1.device)
887
+ // Generate the UIID from which we can match our Homebridge accessory
888
+ const uiid = this.api.hap.uuid.generate(device1.device)
881
889
 
882
- // Don't continue if the accessory doesn't exist
883
- if (!devicesInHB.has(uiid)) {
884
- continue
885
- }
890
+ // Don't continue if the accessory doesn't exist
891
+ if (!devicesInHB.has(uiid)) {
892
+ return
893
+ }
886
894
 
887
- // Retrieve the Homebridge accessory
888
- const accessory = devicesInHB.get(uiid)
895
+ // Retrieve the Homebridge accessory
896
+ const accessory = devicesInHB.get(uiid)
889
897
 
890
- // Make sure the data we need for the device exists
891
- if (!device1.deviceExt || !device1.deviceExt.deviceSettings || !device1.deviceExt.lastDeviceData) {
892
- continue
893
- }
898
+ // Make sure the data we need for the device exists
899
+ if (!device1.deviceExt || !device1.deviceExt.deviceSettings || !device1.deviceExt.lastDeviceData) {
900
+ return
901
+ }
894
902
 
895
- // Parse the data received
896
- let parsedSettings
897
- let parsedData
898
- try {
899
- parsedSettings = JSON.parse(device1.deviceExt.deviceSettings)
900
- parsedData = JSON.parse(device1.deviceExt.lastDeviceData)
901
- } catch {
902
- continue
903
- }
903
+ // Parse the data received
904
+ let parsedSettings
905
+ let parsedData
906
+ try {
907
+ parsedSettings = JSON.parse(device1.deviceExt.deviceSettings)
908
+ parsedData = JSON.parse(device1.deviceExt.lastDeviceData)
909
+ } catch {
910
+ return
911
+ }
904
912
 
905
- const toReturn = { source: 'HTTP' }
906
- if (platformConsts.models.sensorLeak.includes(device1.sku)) {
907
- accessory.logDebug(`raw data: ${JSON.stringify({ ...parsedData, ...parsedSettings })}`)
913
+ const toReturn = { source: 'HTTP' }
914
+ if (platformConsts.models.sensorLeak.includes(device1.sku)) {
915
+ accessory.logDebug(`raw data: ${JSON.stringify({ ...parsedData, ...parsedSettings })}`)
908
916
 
909
- // Keep the gateway details up to date for matching real-time leak
910
- // events relayed by the gateway over AWS (#1276)
911
- if (hasProperty(parsedSettings, 'sno') && parsedSettings.gatewayInfo?.device) {
912
- accessory.context.gatewaySno = parsedSettings.sno
913
- accessory.context.gatewayDevice = parsedSettings.gatewayInfo.device
914
- }
917
+ // Keep the gateway details up to date for matching real-time leak
918
+ // events relayed by the gateway over AWS (#1276)
919
+ if (hasProperty(parsedSettings, 'sno') && parsedSettings.gatewayInfo?.device) {
920
+ accessory.context.gatewaySno = parsedSettings.sno
921
+ accessory.context.gatewayDevice = parsedSettings.gatewayInfo.device
922
+ }
915
923
 
916
- // Leak Sensors - check to see of any warnings if the lastTime is above 0
917
- let hasUnreadLeak = false
918
- if (parsedData.lastTime > 0) {
919
- // Obtain the leak warning messages for this device
920
- const msgs = await this.httpClient.getLeakDeviceWarning(device1.device, device1.sku)
924
+ // Leak Sensors - check to see of any warnings if the lastTime is above 0
925
+ let hasUnreadLeak = false
926
+ if (parsedData.lastTime > 0) {
927
+ // Obtain the leak warning messages for this device
928
+ const msgs = await leakWarningQueue.add(
929
+ () => this.httpClient.getLeakDeviceWarning(device1.device, device1.sku),
930
+ )
921
931
 
922
- accessory.logDebug(`raw messages: ${JSON.stringify(msgs)}`)
932
+ accessory.logDebug(`raw messages: ${JSON.stringify(msgs)}`)
923
933
 
924
- // Check to see if unread messages exist
925
- const unreadCount = msgs.filter(msg => !msg.read && msg.message.toLowerCase().replace(WHITESPACE_REGEX, '').startsWith('leakagealert'))
926
- if (unreadCount.length > 0) {
927
- hasUnreadLeak = true
934
+ // Check to see if unread messages exist
935
+ const unreadCount = msgs.filter(msg => !msg.read && msg.message.toLowerCase().replace(WHITESPACE_REGEX, '').startsWith('leakagealert'))
936
+ if (unreadCount.length > 0) {
937
+ hasUnreadLeak = true
938
+ }
928
939
  }
929
- }
930
940
 
931
- // Generate the params to return
932
- toReturn.battery = parsedSettings.battery
933
- toReturn.leakDetected = hasUnreadLeak
934
- toReturn.online = parsedData.gwonline && parsedData.online
935
- } else if (platformConsts.models.sensorThermo.includes(device1.sku)) {
936
- // Some accounts report `tem` in hundredths of a degree Fahrenheit
937
- // rather than Celsius, which lands in HomeKit as a reading roughly
938
- // 90 degrees too high (#1269). The unit is not stated anywhere in
939
- // the response, so log the raw payload to work out what
940
- // distinguishes those accounts before converting anything.
941
- accessory.logDebug(`[HTTP] raw sensor data: ${JSON.stringify(parsedData)}`)
942
-
943
- if (hasProperty(parsedSettings, 'battery')) {
944
- toReturn.battery = parsedSettings.battery
945
- }
946
- if (hasProperty(parsedData, 'tem')) {
947
- toReturn.temperature = parsedData.tem
948
- }
949
- if (hasProperty(parsedData, 'hum')) {
950
- toReturn.humidity = parsedData.hum
951
- }
952
- if (hasProperty(parsedData, 'online')) {
953
- toReturn.online = parsedData.online
954
- }
955
- } else if (platformConsts.models.sensorMonitor.includes(device1.sku)) {
956
- // The H5106 normally reports over AWS, but some accounts never
957
- // receive those messages, leaving the readings at zero (#1322).
958
- // The HTTP device list carries the same readings, so use it as a
959
- // second source. Every field is guarded and the raw payload is
960
- // logged, since the format for this model is not confirmed on
961
- // every account
962
- accessory.logDebug(`[HTTP] raw sensor data: ${JSON.stringify(parsedData)}`)
963
-
964
- if (hasProperty(parsedSettings, 'battery')) {
941
+ // Generate the params to return
965
942
  toReturn.battery = parsedSettings.battery
943
+ toReturn.leakDetected = hasUnreadLeak
944
+ toReturn.online = parsedData.gwonline && parsedData.online
945
+ } else if (platformConsts.models.sensorThermo.includes(device1.sku)) {
946
+ // Some accounts report `tem` in hundredths of a degree Fahrenheit
947
+ // rather than Celsius, which lands in HomeKit as a reading roughly
948
+ // 90 degrees too high (#1269). The unit is not stated anywhere in
949
+ // the response, so log the raw payload to work out what
950
+ // distinguishes those accounts before converting anything.
951
+ accessory.logDebug(`[HTTP] raw sensor data: ${JSON.stringify(parsedData)}`)
952
+
953
+ if (hasProperty(parsedSettings, 'battery')) {
954
+ toReturn.battery = parsedSettings.battery
955
+ }
956
+ if (hasProperty(parsedData, 'tem')) {
957
+ toReturn.temperature = parsedData.tem
958
+ }
959
+ if (hasProperty(parsedData, 'hum')) {
960
+ toReturn.humidity = parsedData.hum
961
+ }
962
+ if (hasProperty(parsedData, 'online')) {
963
+ toReturn.online = parsedData.online
964
+ }
965
+ } else if (platformConsts.models.sensorMonitor.includes(device1.sku)) {
966
+ // The H5106 normally reports over AWS, but some accounts never
967
+ // receive those messages, leaving the readings at zero (#1322).
968
+ // The HTTP device list carries the same readings, so use it as a
969
+ // second source. Every field is guarded and the raw payload is
970
+ // logged, since the format for this model is not confirmed on
971
+ // every account
972
+ accessory.logDebug(`[HTTP] raw sensor data: ${JSON.stringify(parsedData)}`)
973
+
974
+ if (hasProperty(parsedSettings, 'battery')) {
975
+ toReturn.battery = parsedSettings.battery
976
+ }
977
+ if (hasProperty(parsedData, 'tem')) {
978
+ toReturn.temperature = parsedData.tem
979
+ }
980
+ if (hasProperty(parsedData, 'hum')) {
981
+ toReturn.humidity = parsedData.hum
982
+ }
983
+ if (hasProperty(parsedData, 'online')) {
984
+ toReturn.online = parsedData.online
985
+ }
966
986
  }
967
- if (hasProperty(parsedData, 'tem')) {
968
- toReturn.temperature = parsedData.tem
969
- }
970
- if (hasProperty(parsedData, 'hum')) {
971
- toReturn.humidity = parsedData.hum
972
- }
973
- if (hasProperty(parsedData, 'online')) {
974
- toReturn.online = parsedData.online
975
- }
976
- }
977
987
 
978
- // Send the information to the update receiver function
979
- this.receiveDeviceUpdate(accessory, toReturn)
980
- } catch (err) {
981
- this.log.warn('[%s] %s %s.', device1.deviceName, platformLang.devNotRef, parseError(err))
982
- }
983
- }
988
+ // Send the information to the update receiver function
989
+ this.receiveDeviceUpdate(accessory, toReturn)
990
+ } catch (err) {
991
+ this.log.warn('[%s] %s %s.', device1.deviceName, platformLang.devNotRef, parseError(err))
992
+ }
993
+ }))
984
994
  } catch (err) {
985
995
  this.log.warn('[HTTP] %s %s.', platformLang.syncFail, parseError(err))
986
996
  }
@@ -28,6 +28,10 @@ export function buildCommand(params, context) {
28
28
  return buildOpenApiCommand(params)
29
29
  case 'zoneState':
30
30
  return buildZoneStateCommand(params)
31
+ case 'zoneColour':
32
+ return buildZoneColourCommand(params)
33
+ case 'zoneBrightness':
34
+ return buildZoneBrightnessCommand(params)
31
35
  case 'brightness':
32
36
  return buildBrightnessCommand(params, context)
33
37
  case 'color':
@@ -145,6 +149,123 @@ function buildZoneStateCommand(params) {
145
149
  }
146
150
  }
147
151
 
152
+ /**
153
+ * A zone is addressed by a bitmask over the light's individually-addressable
154
+ * segments, three bytes wide, least significant byte first. Called "light
155
+ * segments" throughout, distinct from the unrelated `segmented`/`segmentedTwo`/
156
+ * etc. config options elsewhere in this plugin, which name a captured scene or
157
+ * BLE code and have nothing to do with per-segment addressing.
158
+ *
159
+ * Two bytes is what every other model in the plugin uses, and is exactly why
160
+ * nothing ever reached the H1250's main panel - that light has seventeen light
161
+ * segments and the seventeenth bit lives in the third byte (#1333).
162
+ *
163
+ * @param {number[]} lightSegments the light segment indices to address (0-23)
164
+ * @returns {number[]} a 3-byte mask, least significant byte first
165
+ */
166
+ function lightSegmentMask(lightSegments) {
167
+ const mask = [0x00, 0x00, 0x00]
168
+ if (!lightSegments.length) {
169
+ throw new Error('No light segments given for a zone command')
170
+ }
171
+ lightSegments.forEach((lightSegment) => {
172
+ if (!Number.isInteger(lightSegment) || lightSegment < 0 || lightSegment > 23) {
173
+ // Past 23 the mask array silently grows a fourth byte, which shifts every
174
+ // byte after it and still checksums cleanly - a wrong frame the device
175
+ // accepts is far harder to spot than an outright failure
176
+ throw new Error(`Light segment [${lightSegment}] is outside the three-byte mask`)
177
+ }
178
+ mask[Math.floor(lightSegment / 8)] |= 1 << (lightSegment % 8)
179
+ })
180
+ return mask
181
+ }
182
+
183
+ /**
184
+ * Builds the `ptReal` frame that sets color on one or more zones of an H1250.
185
+ *
186
+ * Color pads to put the mask at bytes 12-14. Brightness puts it straight after
187
+ * the level, at bytes 5-7. The two layouts really do differ - assuming
188
+ * brightness matched color is what made every early attempt at it fail.
189
+ *
190
+ * @param {object} params the command params
191
+ * @param {object} params.value
192
+ * @param {number} params.value.r 0-255
193
+ * @param {number} params.value.g 0-255
194
+ * @param {number} params.value.b 0-255
195
+ * @param {number[]} params.lightSegments the light segment indices to address
196
+ * @returns {{ awsParams: object }} the AWS-only command params
197
+ */
198
+ function buildZoneColourCommand(params) {
199
+ if (!params.value || !params.lightSegments) {
200
+ throw new Error(`Missing value or light segments for ${params.cmd}`)
201
+ }
202
+ const { r, g, b } = params.value
203
+ return {
204
+ // AWS only, for the same reason as buildZoneStateCommand
205
+ awsParams: {
206
+ cmd: 'ptReal',
207
+ data: {
208
+ command: [generateCodeFromHexValues([
209
+ 0x33,
210
+ 0x05,
211
+ 0x15,
212
+ 0x01,
213
+ r,
214
+ g,
215
+ b,
216
+ 0x00,
217
+ 0x00,
218
+ 0x00,
219
+ 0x00,
220
+ 0x00,
221
+ ...lightSegmentMask(params.lightSegments),
222
+ ])],
223
+ },
224
+ },
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The frame that sets a zone's brightness.
230
+ *
231
+ * Sent as-is, with no 0-254 scaling like `buildBrightnessCommand` applies for
232
+ * the whole-device AWS command - this is deliberate, not an oversight. The zone
233
+ * frame's level byte is already 0-100, the same scale HomeKit uses.
234
+ *
235
+ * @param {object} params
236
+ * @param {number[]} params.lightSegments the light segment indices to set
237
+ * @param {number} params.value 0-100, the same scale HomeKit uses
238
+ * @returns {{ awsParams: object }} the raw frame to send over AWS -
239
+ * for the same reason as `buildZoneColourCommand`, this device does not
240
+ * get a LAN path
241
+ */
242
+ function buildZoneBrightnessCommand(params) {
243
+ if (params.value === undefined || !params.lightSegments) {
244
+ throw new Error(`Missing value or light segments for ${params.cmd}`)
245
+ }
246
+ if (!Number.isInteger(params.value) || params.value < 0 || params.value > 100) {
247
+ // Out of range the byte silently wraps - 300 becomes 44 - and the frame
248
+ // still checksums cleanly, so the light takes a brightness nobody asked
249
+ // for rather than the command failing outright
250
+ throw new Error(`Brightness [${params.value}] is outside 0-100 for ${params.cmd}`)
251
+ }
252
+ return {
253
+ awsParams: {
254
+ cmd: 'ptReal',
255
+ data: {
256
+ command: [generateCodeFromHexValues([
257
+ 0x33,
258
+ 0x05,
259
+ 0x15,
260
+ 0x02,
261
+ params.value,
262
+ ...lightSegmentMask(params.lightSegments),
263
+ ])],
264
+ },
265
+ },
266
+ }
267
+ }
268
+
148
269
  function buildBrightnessCommand(params, context) {
149
270
  const caps = getDeviceCapabilities(context.gvModel, context.firmware)
150
271
  return {
@@ -742,7 +742,24 @@ export default {
742
742
  //
743
743
  // The H1270 is deliberately absent. It advertises the same capabilities, but
744
744
  // nobody has confirmed it shares the fault, so it stays on the toggle path.
745
- zoneLightModels: ['H1250'],
745
+ /**
746
+ * Which light segments make up each zone of a two-zone light. Called "light
747
+ * segments" to avoid any confusion with the unrelated `segmented`/
748
+ * `segmentedTwo`/`segmentedThree`/`segmentedFour` config options above,
749
+ * which name a captured scene or BLE code.
750
+ *
751
+ * How many light segments a light has can be read back from its own report,
752
+ * but which segments belong to which zone cannot - nothing in the frame says
753
+ * so. The H1250's split was established by changing one zone at a time and
754
+ * watching which segments moved, so this is configuration, and a new model
755
+ * should be measured rather than assumed to share the layout (#1333).
756
+ */
757
+ zoneLightModels: {
758
+ H1250: {
759
+ main: [16],
760
+ background: Array.from({ length: 16 }, (_, index) => index),
761
+ },
762
+ },
746
763
 
747
764
  matterModels: [
748
765
  'H1401',
@@ -80,6 +80,12 @@ const modelOverrides = {
80
80
  H1401: { awsBrightnessNoScale: true },
81
81
  H6008: { awsBrightnessNoScale: true },
82
82
  H6022: { awsBrightnessNoScale: true },
83
+
84
+ // H1250: proven live on hardware 2026-08-08 - a HomeKit write of 100 sent
85
+ // val:254 and came out visibly dimmer than the Govee app's own 100%, then the
86
+ // AWS echo of {"brightness":100} was rescaled down to 39%, matching every
87
+ // step of a slider drag exactly (98->39, 69->27, 42->17, 97->38, 43->17)
88
+ H1250: { awsBrightnessNoScale: true },
83
89
  }
84
90
 
85
91
  /**
@@ -23,12 +23,19 @@ import platformConsts from './constants.js'
23
23
  * out still gets power rather than being left unsupported
24
24
  * - `choose` for the few lists where the answer depends on the device or the
25
25
  * owner's settings
26
- * - `awsPolling` these devices need their status asking for over AWS
26
+ * - `awsPolling` these devices need their status asking for over AWS. Either a
27
+ * boolean for every model in the list, or a function of the same args as
28
+ * `choose`, for a list where only some models need it.
27
29
  */
28
30
  const routes = [
29
31
  {
30
32
  list: 'rgb',
31
33
  choose: ({ deviceConf }) => (deviceConf.showAs === 'switch' ? 'deviceLightSwitch' : 'deviceLight'),
34
+ // A light segment layout means this model's zone data only ever arrives
35
+ // in reply to a full AWS status request - LAN never carries it. Every
36
+ // other light gets everything it needs from LAN polling, so only these
37
+ // need the extra AWS traffic
38
+ awsPolling: ({ device }) => Boolean(platformConsts.zoneLightModels[device.model]),
32
39
  },
33
40
  { list: 'switchSingle', choose: chooseSingleSwitch },
34
41
  {
@@ -149,11 +156,15 @@ export function resolveDeviceType(device, deviceConf = {}, cachedContext = undef
149
156
  return { skip: chosen.skip, list: route.list }
150
157
  }
151
158
 
159
+ const awsPolling = typeof route.awsPolling === 'function'
160
+ ? route.awsPolling({ device, deviceConf, cachedContext })
161
+ : !!route.awsPolling
162
+
152
163
  return {
153
164
  list: route.list,
154
165
  handlerKey: chosen.handler,
155
166
  handler: deviceTypes[chosen.handler],
156
- awsPolling: !!route.awsPolling,
167
+ awsPolling,
157
168
  externalCategory: chosen.externalCategory,
158
169
  }
159
170
  }
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.34.2-beta.4",
5
+ "version": "11.34.2-beta.6",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",