@homebridge-plugins/homebridge-govee 11.34.2-beta.1 → 11.34.2-beta.10

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
@@ -9,6 +9,21 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
9
9
  - feat: control the H1250 zones with the device's own frame, as govee ignores its toggles (#1343) (@Principe1218)
10
10
  - fix: explain in the log when zone tiles are missing because aws credentials are not set
11
11
  - fix: restore debug logging when the plugin runs in a child bridge
12
+ - fix: turn adaptive lighting off when a colour is chosen outside HomeKit
13
+ - fix: send a colour change when only the saturation moves, on every night light
14
+ - fix: listen for saturation on the main light too, not only hue
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)
18
+ - fix: stop a lan scan that cannot reach the network from crashing the bridge
19
+ - fix: apply the openapi disable and refresh settings instead of asking for them to be removed
20
+ - fix: say once when a bluetooth reading arrives for an unknown device, not on every broadcast
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 (#1348) (@danieloleary)
23
+ - fix: mark sensor batteries low at the configured threshold (#1349) (@danieloleary)
24
+ - feat: show whether the kettle is heating, keeping warm or idle (#1337)
25
+ - fix: give the H5106 a battery service, so its low battery setting does something
26
+ - fix: stop a light's broadcast being mistaken for an h5101 sensor reading
12
27
 
13
28
  ## v11.34.1 (2026-08-06)
14
29
 
@@ -49,7 +49,8 @@
49
49
  "type": "integer",
50
50
  "description": "An interval (in seconds) in which your HTTP devices will refresh with the plugin. Must be 30 or more.",
51
51
  "placeholder": 30,
52
- "minimum": 30
52
+ "minimum": 30,
53
+ "maximum": 2073600
53
54
  },
54
55
  "awsDisable": {
55
56
  "title": "Disable AWS",
@@ -69,7 +70,8 @@
69
70
  "minimum": 60,
70
71
  "condition": {
71
72
  "functionBody": "return (!model.bleDisable);"
72
- }
73
+ },
74
+ "maximum": 2073600
73
75
  },
74
76
  "lanDisable": {
75
77
  "title": "Disable LAN",
@@ -84,7 +86,8 @@
84
86
  "minimum": 10,
85
87
  "condition": {
86
88
  "functionBody": "return (!model.lanDisable);"
87
- }
89
+ },
90
+ "maximum": 2073600
88
91
  },
89
92
  "lanScanInterval": {
90
93
  "title": "LAN Scan Interval",
@@ -94,7 +97,8 @@
94
97
  "minimum": 30,
95
98
  "condition": {
96
99
  "functionBody": "return (!model.lanDisable);"
97
- }
100
+ },
101
+ "maximum": 2073600
98
102
  },
99
103
  "lanScanSubnets": {
100
104
  "title": "LAN Scan Subnets",
@@ -118,7 +122,8 @@
118
122
  "minimum": 30,
119
123
  "condition": {
120
124
  "functionBody": "return (!model.openApiDisable);"
121
- }
125
+ },
126
+ "maximum": 2073600
122
127
  },
123
128
  "bleControlInterval": {
124
129
  "title": "BLE Control Interval",
@@ -181,6 +181,12 @@ export default class {
181
181
  reject(err)
182
182
  })
183
183
 
184
+ // Handle errors on the sending socket. Without a listener here, node throws
185
+ // the error object instead of emitting it, which takes the bridge down.
186
+ this.sender.on('error', (err) => {
187
+ this.log.warn('[LAN] client error: %s.', parseError(err))
188
+ })
189
+
184
190
  // Handle started listening for messages
185
191
  this.receiver.on('listening', () => {
186
192
  const { address, port } = this.receiver.address()
@@ -233,16 +239,25 @@ export default class {
233
239
  sendScanCommand() {
234
240
  const scanCommand = JSON.stringify({ msg: { cmd: commands.scan, data: { account_topic: 'reserve' } } })
235
241
  this.log.debug('[LAN] scanning for devices over LAN...')
236
- this.sender.send(scanCommand, scanCommandPort, multicastIp)
242
+ this.sender.send(scanCommand, scanCommandPort, multicastIp, err => this.logScanSendError(multicastIp, err))
237
243
 
238
244
  // Also scan any configured subnets directly. Multicast doesn't cross
239
245
  // between subnets, so devices on a different subnet than Homebridge are
240
246
  // reached by sending the scan straight to each host (they reply on 4002).
241
247
  this.scanHosts.forEach((host) => {
242
- this.sender.send(scanCommand, scanCommandPort, host)
248
+ this.sender.send(scanCommand, scanCommandPort, host, err => this.logScanSendError(host, err))
243
249
  })
244
250
  }
245
251
 
252
+ // A scan send can fail on a host with no route to the target, for example when
253
+ // multicast is unavailable. Report it and carry on rather than letting dgram
254
+ // emit an unhandled 'error' event, which would crash the bridge every scan.
255
+ logScanSendError(target, err) {
256
+ if (err) {
257
+ this.log.debug('[LAN] scan request to %s failed [%s].', target, parseError(err))
258
+ }
259
+ }
260
+
246
261
  // Get all available LAN devices
247
262
  async getDevices() {
248
263
  return new Promise((resolve) => {
@@ -53,6 +53,27 @@ export default class GoveeDevice {
53
53
  return !!existing
54
54
  }
55
55
 
56
+ updateBattery(battery) {
57
+ if (!this.battService || !Number.isFinite(battery) || battery < 0 || battery > 100) {
58
+ return
59
+ }
60
+
61
+ if (battery !== this.cacheBatt) {
62
+ this.cacheBatt = battery
63
+ this.battService.updateCharacteristic(this.hapChar.BatteryLevel, this.cacheBatt)
64
+ this.accessory.log(`${platformLang.curBatt} [${this.cacheBatt}%]`)
65
+ }
66
+
67
+ const lowBattery = battery <= this.lowBattThreshold ? 1 : 0
68
+ const currentLowBattery = Number(this.battService
69
+ .getCharacteristic(this.hapChar.StatusLowBattery)
70
+ .value)
71
+
72
+ if (lowBattery !== currentLowBattery) {
73
+ this.battService.updateCharacteristic(this.hapChar.StatusLowBattery, lowBattery)
74
+ }
75
+ }
76
+
56
77
  /**
57
78
  * Give up on a control, put it back the way it was, and tell HomeKit.
58
79
  *
@@ -111,6 +111,14 @@ export default class extends GoveeDevice {
111
111
  this.lightService.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
112
112
  await this.internalColourUpdate(value)
113
113
  })
114
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
115
+ // separately, so with only hue listened for, dragging saturation alone -
116
+ // the same colour made paler or richer - reached nothing and the light
117
+ // never changed. Both handlers share one update, and the debounce inside
118
+ // it collapses a hue-and-saturation pair into a single command.
119
+ this.lightService.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
120
+ await this.internalColourUpdate(this.lightService.getCharacteristic(this.hapChar.Hue).value)
121
+ })
114
122
  this.cacheHue = this.lightService.getCharacteristic(this.hapChar.Hue).value
115
123
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
116
124
  }
@@ -319,13 +327,16 @@ export default class extends GoveeDevice {
319
327
  return
320
328
  }
321
329
 
322
- // Don't continue if the new value is the same as before
323
- if (value === this.cacheHue) {
330
+ // Don't continue if the new colour is the same as before. Saturation is
331
+ // part of that question: comparing hue alone meant a change of saturation
332
+ // at the same hue looked like no change at all, and nothing was sent.
333
+ const currentSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
334
+ if (value === this.cacheHue && currentSat === this.cacheSat) {
324
335
  return
325
336
  }
326
337
 
327
338
  // Calculate RGB values
328
- const newRGB = hs2rgb(value, this.lightService.getCharacteristic(this.hapChar.Saturation).value)
339
+ const newRGB = hs2rgb(value, currentSat)
329
340
 
330
341
  // Generate the hex values for the code
331
342
  const hexValues = [0x3A, 0x1B, 0x05, 0x0D, ...newRGB]
@@ -336,7 +347,10 @@ export default class extends GoveeDevice {
336
347
  value: generateCodeFromHexValues(hexValues),
337
348
  })
338
349
 
339
- // Cache the new state and log if appropriate
350
+ // Cache the new state and log if appropriate. Saturation is cached on
351
+ // every send, not only when the hue moved, or a change of saturation at
352
+ // the same hue would be sent once and then look unchanged ever after.
353
+ this.cacheSat = currentSat
340
354
  if (this.cacheHue !== value) {
341
355
  this.cacheHue = value
342
356
  this.accessory.log(`${platformLang.curColour} [rgb ${newRGB.join(' ')}]`)
@@ -450,12 +464,14 @@ export default class extends GoveeDevice {
450
464
 
451
465
  const hs = rgb2hs(newR, newG, newB)
452
466
 
453
- // Check for a colour change
454
- if (hs[0] !== this.cacheHue) {
467
+ // Check for a colour change. Saturation counts as much as hue, or a
468
+ // change of saturation made in the Govee app is reported back and
469
+ // ignored, leaving the tile showing the old colour.
470
+ if (hs[0] !== this.cacheHue || hs[1] !== this.cacheSat) {
455
471
  // Colour is different so update Homebridge with new values
456
472
  this.lightService.updateCharacteristic(this.hapChar.Hue, hs[0])
457
473
  this.lightService.updateCharacteristic(this.hapChar.Saturation, hs[1]);
458
- [this.cacheHue] = hs
474
+ [this.cacheHue, this.cacheSat] = hs
459
475
 
460
476
  // Log the change
461
477
  this.accessory.log(`${platformLang.curColour} [rgb ${newR} ${newG} ${newB}]`)
@@ -111,6 +111,14 @@ export default class extends GoveeDevice {
111
111
  this.lightService.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
112
112
  await this.internalColourUpdate(value)
113
113
  })
114
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
115
+ // separately, so with only hue listened for, dragging saturation alone -
116
+ // the same colour made paler or richer - reached nothing and the light
117
+ // never changed. Both handlers share one update, and the debounce inside
118
+ // it collapses a hue-and-saturation pair into a single command.
119
+ this.lightService.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
120
+ await this.internalColourUpdate(this.lightService.getCharacteristic(this.hapChar.Hue).value)
121
+ })
114
122
  this.cacheHue = this.lightService.getCharacteristic(this.hapChar.Hue).value
115
123
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
116
124
  }
@@ -323,13 +331,16 @@ export default class extends GoveeDevice {
323
331
  return
324
332
  }
325
333
 
326
- // Don't continue if the new value is the same as before
327
- if (value === this.cacheHue) {
334
+ // Don't continue if the new colour is the same as before. Saturation is
335
+ // part of that question: comparing hue alone meant a change of saturation
336
+ // at the same hue looked like no change at all, and nothing was sent.
337
+ const currentSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
338
+ if (value === this.cacheHue && currentSat === this.cacheSat) {
328
339
  return
329
340
  }
330
341
 
331
342
  // Calculate RGB values
332
- const newRGB = hs2rgb(value, this.lightService.getCharacteristic(this.hapChar.Saturation).value)
343
+ const newRGB = hs2rgb(value, currentSat)
333
344
 
334
345
  // Generate the hex values for the code
335
346
  const hexValues = [0x3A, 0x1B, 0x05, 0x0D, ...newRGB]
@@ -340,7 +351,10 @@ export default class extends GoveeDevice {
340
351
  value: generateCodeFromHexValues(hexValues),
341
352
  })
342
353
 
343
- // Cache the new state and log if appropriate
354
+ // Cache the new state and log if appropriate. Saturation is cached on
355
+ // every send, not only when the hue moved, or a change of saturation at
356
+ // the same hue would be sent once and then look unchanged ever after.
357
+ this.cacheSat = currentSat
344
358
  if (this.cacheHue !== value) {
345
359
  this.cacheHue = value
346
360
  this.accessory.log(`${platformLang.curColour} [rgb ${newRGB.join(' ')}]`)
@@ -454,12 +468,14 @@ export default class extends GoveeDevice {
454
468
 
455
469
  const hs = rgb2hs(newR, newG, newB)
456
470
 
457
- // Check for a colour change
458
- if (hs[0] !== this.cacheHue) {
471
+ // Check for a colour change. Saturation counts as much as hue, or a
472
+ // change of saturation made in the Govee app is reported back and
473
+ // ignored, leaving the tile showing the old colour.
474
+ if (hs[0] !== this.cacheHue || hs[1] !== this.cacheSat) {
459
475
  // Colour is different so update Homebridge with new values
460
476
  this.lightService.updateCharacteristic(this.hapChar.Hue, hs[0])
461
477
  this.lightService.updateCharacteristic(this.hapChar.Saturation, hs[1]);
462
- [this.cacheHue] = hs
478
+ [this.cacheHue, this.cacheSat] = hs
463
479
 
464
480
  // Log the change
465
481
  this.accessory.log(`${platformLang.curColour} [rgb ${newR} ${newG} ${newB}]`)
@@ -221,6 +221,14 @@ export default class extends GoveeDevice {
221
221
  this.lightService.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
222
222
  await this.internalColourUpdate(value)
223
223
  })
224
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
225
+ // separately, so with only hue listened for, dragging saturation alone -
226
+ // the same colour made paler or richer - reached nothing and the light
227
+ // never changed. Both handlers share one update, and the debounce inside
228
+ // it collapses a hue-and-saturation pair into a single command.
229
+ this.lightService.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
230
+ await this.internalColourUpdate(this.lightService.getCharacteristic(this.hapChar.Hue).value)
231
+ })
224
232
  this.cacheHue = this.lightService.getCharacteristic(this.hapChar.Hue).value
225
233
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
226
234
 
@@ -574,13 +582,16 @@ export default class extends GoveeDevice {
574
582
  return
575
583
  }
576
584
 
577
- // Don't continue if the new value is the same as before
578
- if (value === this.cacheHue) {
585
+ // Don't continue if the new colour is the same as before. Saturation is
586
+ // part of that question: comparing hue alone meant a change of saturation
587
+ // at the same hue looked like no change at all, and nothing was sent.
588
+ const currentSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
589
+ if (value === this.cacheHue && currentSat === this.cacheSat) {
579
590
  return
580
591
  }
581
592
 
582
593
  // Calculate RGB values
583
- const newRGB = hs2rgb(value, this.lightService.getCharacteristic(this.hapChar.Saturation).value)
594
+ const newRGB = hs2rgb(value, currentSat)
584
595
 
585
596
  // Generate the hex values for the code
586
597
  const hexValues = [0x3A, 0x1B, 0x05, 0x0D, ...newRGB]
@@ -591,7 +602,10 @@ export default class extends GoveeDevice {
591
602
  value: generateCodeFromHexValues(hexValues),
592
603
  })
593
604
 
594
- // Cache the new state and log if appropriate
605
+ // Cache the new state and log if appropriate. Saturation is cached on
606
+ // every send, not only when the hue moved, or a change of saturation at
607
+ // the same hue would be sent once and then look unchanged ever after.
608
+ this.cacheSat = currentSat
595
609
  if (this.cacheHue !== value) {
596
610
  this.cacheHue = value
597
611
  this.accessory.log(`${platformLang.curColour} [rgb ${newRGB.join(' ')}]`)
@@ -731,12 +745,14 @@ export default class extends GoveeDevice {
731
745
 
732
746
  const hs = rgb2hs(newR, newG, newB)
733
747
 
734
- // Check for a colour change
735
- if (hs[0] !== this.cacheHue) {
748
+ // Check for a colour change. Saturation counts as much as hue, or a
749
+ // change of saturation made in the Govee app is reported back and
750
+ // ignored, leaving the tile showing the old colour.
751
+ if (hs[0] !== this.cacheHue || hs[1] !== this.cacheSat) {
736
752
  // Colour is different so update Homebridge with new values
737
753
  this.lightService.updateCharacteristic(this.hapChar.Hue, hs[0])
738
754
  this.lightService.updateCharacteristic(this.hapChar.Saturation, hs[1]);
739
- [this.cacheHue] = hs
755
+ [this.cacheHue, this.cacheSat] = hs
740
756
 
741
757
  // Log the change
742
758
  this.accessory.log(`${platformLang.curColour} [rgb ${newR} ${newG} ${newB}]`)
@@ -122,6 +122,14 @@ export default class extends GoveeDevice {
122
122
  this.lightService.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
123
123
  await this.internalColourUpdate(value)
124
124
  })
125
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
126
+ // separately, so with only hue listened for, dragging saturation alone -
127
+ // the same colour made paler or richer - reached nothing and the light
128
+ // never changed. Both handlers share one update, and the debounce inside
129
+ // it collapses a hue-and-saturation pair into a single command.
130
+ this.lightService.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
131
+ await this.internalColourUpdate(this.lightService.getCharacteristic(this.hapChar.Hue).value)
132
+ })
125
133
  this.cacheHue = this.lightService.getCharacteristic(this.hapChar.Hue).value
126
134
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
127
135
 
@@ -318,15 +326,18 @@ export default class extends GoveeDevice {
318
326
  return
319
327
  }
320
328
 
321
- // Don't continue if the new value is the same as before
322
- if (value === this.cacheHue) {
329
+ // Don't continue if the new colour is the same as before. Saturation is
330
+ // part of that question: comparing hue alone meant a change of saturation
331
+ // at the same hue looked like no change at all, and nothing was sent.
332
+ const currentSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
333
+ if (value === this.cacheHue && currentSat === this.cacheSat) {
323
334
  return
324
335
  }
325
336
 
326
337
  // Generate the hex values for the code
327
338
  const newRGB = hs2rgb(
328
339
  value,
329
- this.lightService.getCharacteristic(this.hapChar.Saturation).value,
340
+ currentSat,
330
341
  )
331
342
  const hexValues = [0x33, 0x1B, 0x01, this.cacheBright, ...newRGB]
332
343
 
@@ -336,7 +347,10 @@ export default class extends GoveeDevice {
336
347
  value: generateCodeFromHexValues(hexValues),
337
348
  })
338
349
 
339
- // Cache the new state and log if appropriate
350
+ // Cache the new state and log if appropriate. Saturation is cached on
351
+ // every send, not only when the hue moved, or a change of saturation at
352
+ // the same hue would be sent once and then look unchanged ever after.
353
+ this.cacheSat = currentSat
340
354
  if (this.cacheHue !== value) {
341
355
  this.cacheHue = value
342
356
  this.accessory.log(`${platformLang.curColour} [rgb ${newRGB.join(' ')}]`)
@@ -487,12 +501,14 @@ export default class extends GoveeDevice {
487
501
  const newB = hexToDecimal(getTwoItemPosition(hexParts, 7))
488
502
  const hs = rgb2hs(newR, newG, newB)
489
503
 
490
- // Check for a colour change
491
- if (hs[0] !== this.cacheHue) {
504
+ // Check for a colour change. Saturation counts as much as hue, or a
505
+ // change of saturation made in the Govee app is reported back and
506
+ // ignored, leaving the tile showing the old colour.
507
+ if (hs[0] !== this.cacheHue || hs[1] !== this.cacheSat) {
492
508
  // Colour is different so update Homebridge with new values
493
509
  this.lightService.updateCharacteristic(this.hapChar.Hue, hs[0])
494
510
  this.lightService.updateCharacteristic(this.hapChar.Saturation, hs[1]);
495
- [this.cacheHue] = hs
511
+ [this.cacheHue, this.cacheSat] = hs
496
512
 
497
513
  // Log the change
498
514
  this.accessory.log(`${platformLang.curColour} [rgb ${newR} ${newG} ${newB}]`)
@@ -85,6 +85,14 @@ export default class extends GoveeDevice {
85
85
  this.lightService.getCharacteristic(this.hapChar.Hue).onSet(async (value) => {
86
86
  await this.internalColourUpdate(value)
87
87
  })
88
+ // Saturation needs a handler of its own. HomeKit writes hue and saturation
89
+ // separately, so with only hue listened for, dragging saturation alone -
90
+ // the same colour made paler or richer - reached nothing and the light
91
+ // never changed. Both handlers share one update, and the debounce inside
92
+ // it collapses a hue-and-saturation pair into a single command.
93
+ this.lightService.getCharacteristic(this.hapChar.Saturation).onSet(async () => {
94
+ await this.internalColourUpdate(this.lightService.getCharacteristic(this.hapChar.Hue).value)
95
+ })
88
96
  this.cacheHue = this.lightService.getCharacteristic(this.hapChar.Hue).value
89
97
  this.cacheSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
90
98
 
@@ -266,15 +274,18 @@ export default class extends GoveeDevice {
266
274
  return
267
275
  }
268
276
 
269
- // Don't continue if the new value is the same as before
270
- if (value === this.cacheHue) {
277
+ // Don't continue if the new colour is the same as before. Saturation is
278
+ // part of that question: comparing hue alone meant a change of saturation
279
+ // at the same hue looked like no change at all, and nothing was sent.
280
+ const currentSat = this.lightService.getCharacteristic(this.hapChar.Saturation).value
281
+ if (value === this.cacheHue && currentSat === this.cacheSat) {
271
282
  return
272
283
  }
273
284
 
274
285
  // Generate the hex values for the code
275
286
  const newRGB = hs2rgb(
276
287
  value,
277
- this.lightService.getCharacteristic(this.hapChar.Saturation).value,
288
+ currentSat,
278
289
  )
279
290
  const hexValues = [0x33, 0x1B, 0x01, this.cacheBright, ...newRGB]
280
291
 
@@ -284,7 +295,10 @@ export default class extends GoveeDevice {
284
295
  value: generateCodeFromHexValues(hexValues),
285
296
  })
286
297
 
287
- // Cache the new state and log if appropriate
298
+ // Cache the new state and log if appropriate. Saturation is cached on
299
+ // every send, not only when the hue moved, or a change of saturation at
300
+ // the same hue would be sent once and then look unchanged ever after.
301
+ this.cacheSat = currentSat
288
302
  if (this.cacheHue !== value) {
289
303
  this.cacheHue = value
290
304
  this.accessory.log(`${platformLang.curColour} [rgb ${newRGB.join(' ')}]`)
@@ -411,12 +425,14 @@ export default class extends GoveeDevice {
411
425
  const newB = hexToDecimal(getTwoItemPosition(hexParts, 7))
412
426
  const hs = rgb2hs(newR, newG, newB)
413
427
 
414
- // Check for a colour change
415
- if (hs[0] !== this.cacheHue) {
428
+ // Check for a colour change. Saturation counts as much as hue, or a
429
+ // change of saturation made in the Govee app is reported back and
430
+ // ignored, leaving the tile showing the old colour.
431
+ if (hs[0] !== this.cacheHue || hs[1] !== this.cacheSat) {
416
432
  // Colour is different so update Homebridge with new values
417
433
  this.lightService.updateCharacteristic(this.hapChar.Hue, hs[0])
418
434
  this.lightService.updateCharacteristic(this.hapChar.Saturation, hs[1]);
419
- [this.cacheHue] = hs
435
+ [this.cacheHue, this.cacheSat] = hs
420
436
 
421
437
  // Log the change
422
438
  this.accessory.log(`${platformLang.curColour} [rgb ${newR} ${newG} ${newB}]`)
@@ -28,12 +28,16 @@ import GoveeDevice from './base.js'
28
28
  /**
29
29
  * The water temperature out of a status frame, in celsius.
30
30
  *
31
- * The frame carries it as two bytes of hundredths of a degree fahrenheit. That
32
- * layout has never been confirmed against a kettle sitting at a known
33
- * temperature, so the result is checked before it is used: a kettle holds
34
- * water, and a figure outside freezing to a little past boiling is not a
35
- * temperature at all - it means this model packs the frame differently.
36
- * Returning nothing then leaves the tile absent rather than wrong.
31
+ * The frame carries it as two bytes of hundredths of a degree fahrenheit. An
32
+ * owner confirmed that layout against three known points on an H7171 (#1337):
33
+ * cold water read 0x1edc = 79.00F, the moment the kettle beeped at its 142F
34
+ * target it read 0x3778 = 142.00F, and it drifted to 0x39d0 = 148.00F after.
35
+ *
36
+ * The result is still checked before it is used, because only the one model has
37
+ * been seen: a kettle holds water, and a figure outside freezing to a little
38
+ * past boiling is not a temperature at all - it means that model packs the
39
+ * frame differently. Returning nothing then leaves the tile absent rather than
40
+ * wrong.
37
41
  *
38
42
  * @param {string[]} hexParts the frame split into bytes
39
43
  * @returns {number|undefined} the reading in celsius, or undefined if the
@@ -51,6 +55,33 @@ function readingToCelsius(hexParts) {
51
55
  return farToCen(fahrenheit)
52
56
  }
53
57
 
58
+ /**
59
+ * What the kettle says it is doing, from the state byte of an `aa 19` frame.
60
+ *
61
+ * Worked out from a full run an owner instrumented for us (#1337), matching the
62
+ * frames against a running commentary of what the kettle was doing:
63
+ *
64
+ * 00 idle two seconds after they switched it off
65
+ * 01 heating the moment they turned it on, and a second after setting a
66
+ * temperature on an earlier run
67
+ * 04 reached as the water hit the target and the kettle beeped
68
+ * 02 warming held for the next three and a half minutes of keep warm
69
+ *
70
+ * `04` and `02` arrive in the same second, so which of the pair marks reaching
71
+ * the target and which marks entering keep warm is read from their order, not
72
+ * proven separately. Both leave the kettle no longer heating, which is what the
73
+ * sensor reflects, so the distinction only affects the wording in the log.
74
+ *
75
+ * Only these four have been seen, all with keep warm switched on. Anything else
76
+ * falls through to the unknown-data report, which is how these were found.
77
+ */
78
+ const HEATING_STATES = {
79
+ '00': { label: 'idle', heating: false },
80
+ '01': { label: 'heating', heating: true },
81
+ '02': { label: 'keeping warm', heating: false },
82
+ '04': { label: 'reached target', heating: false },
83
+ }
84
+
54
85
  export default class extends GoveeDevice {
55
86
  constructor(platform, accessory) {
56
87
  super(platform, accessory)
@@ -294,6 +325,45 @@ export default class extends GoveeDevice {
294
325
  }
295
326
  }
296
327
 
328
+ /**
329
+ * Reflect what the kettle says it is doing.
330
+ *
331
+ * The tile is added the first time a state frame arrives rather than up
332
+ * front, for the same reason as the temperature tile above: only the H7171
333
+ * has been seen sending these, and a tile that never updates cannot be told
334
+ * apart from a real reading.
335
+ *
336
+ * @param {string} stateByte the state byte of an `aa 19` frame
337
+ */
338
+ updateHeatingState(stateByte) {
339
+ const state = HEATING_STATES[stateByte]
340
+ if (!state) {
341
+ return
342
+ }
343
+
344
+ if (!this.serviceHeating) {
345
+ this.serviceHeating = this.accessory.getService('Heating')
346
+ if (!this.serviceHeating) {
347
+ this.serviceHeating = this.accessory.addService(this.hapServ.ContactSensor, 'Heating', 'heating')
348
+ this.serviceHeating.addCharacteristic(this.hapChar.ConfiguredName)
349
+ this.serviceHeating.updateCharacteristic(this.hapChar.ConfiguredName, 'Heating')
350
+ }
351
+ }
352
+
353
+ if (this.cacheHeatingState === state.label) {
354
+ return
355
+ }
356
+ this.cacheHeatingState = state.label
357
+
358
+ this.serviceHeating.updateCharacteristic(
359
+ this.hapChar.ContactSensorState,
360
+ state.heating
361
+ ? this.hapChar.ContactSensorState.CONTACT_DETECTED
362
+ : this.hapChar.ContactSensorState.CONTACT_NOT_DETECTED,
363
+ )
364
+ this.accessory.log(`current status [${state.label}]`)
365
+ }
366
+
297
367
  externalUpdate(params) {
298
368
  // Handle OpenAPI workMode
299
369
  if (params.workMode) {
@@ -369,13 +439,23 @@ export default class extends GoveeDevice {
369
439
  case '2301': { // scheduled start on
370
440
  break
371
441
  }
442
+ // What the kettle is doing - see HEATING_STATES above. `1900` used to be
443
+ // treated as a startup frame, which it only looked like because a kettle
444
+ // is usually idle when it connects; it is really the idle state and is
445
+ // sent whenever the kettle reaches it.
446
+ case '1900':
447
+ case '1901':
448
+ case '1902':
449
+ case '1904': {
450
+ this.updateHeatingState(getTwoItemPosition(hexParts, 3))
451
+ break
452
+ }
372
453
  // Also sent on startup, alongside the preset list above. What each one
373
454
  // carries has not been worked out, and nothing here needs them, so they
374
455
  // are known-and-ignored rather than reported every time (#1337)
375
456
  case '1701': // a second form of the on-base report
376
457
  case '1f06':
377
- case '1f08':
378
- case '1900': {
458
+ case '1f08': {
379
459
  this.accessory.logDebug(`startup status [${deviceFunction}]: ${hexString}`)
380
460
  break
381
461
  }