@homebridge-plugins/homebridge-govee 11.34.2-beta.3 → 11.34.2-beta.5
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 +3 -1
- package/lib/device/light.js +447 -25
- package/lib/utils/command-builder.js +121 -0
- package/lib/utils/constants.js +18 -1
- package/lib/utils/device-capabilities.js +6 -0
- package/lib/utils/dispatch.js +13 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,7 +12,9 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
12
12
|
- fix: turn adaptive lighting off when a colour is chosen outside HomeKit
|
|
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
|
package/lib/device/light.js
CHANGED
|
@@ -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
|
|
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
|
|
256
|
-
//
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
|
@@ -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 {
|
package/lib/utils/constants.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/lib/utils/dispatch.js
CHANGED
|
@@ -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
|
|
167
|
+
awsPolling,
|
|
157
168
|
externalCategory: chosen.externalCategory,
|
|
158
169
|
}
|
|
159
170
|
}
|
package/package.json
CHANGED