@homebridge-plugins/homebridge-govee 11.37.1 → 11.38.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
+ ## v11.38.0 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - feat: show which slot the kettle target temperature comes from, settled by two owner runs
10
+ - fix(h1370): send fan power to the fan instead of the lights, and give it the two light tiles
11
+ - feat(h1370): add the oscillation control, read from the fan's own speed frame
12
+
5
13
  ## v11.37.1 (2026-08-21)
6
14
 
7
15
  ### Changed
@@ -15,15 +15,22 @@ import { logUnknownData } from '../utils/report-unknown.js'
15
15
  import GoveeDevice from './base.js'
16
16
 
17
17
  /*
18
- Codes below were decoded from a real device's AWS status reports in
19
- https://github.com/homebridge-plugins/homebridge-govee/issues/1307
20
- Status aa 31 [?] [speed] [direction] = fan speed and airflow direction
21
- Status aa 36 01 01 / aa 36 00 00 = fan power on/off
22
- The light reports through the standard state/brightness/kelvin fields
23
- (colour temperature only, 2700-6500K)
24
-
25
- The H1310 and R1310 share this handler. They report six speeds against the
26
- H1370's twelve, so the scale comes from the capabilities table (#1352)
18
+ ⚠️ This is the BASE for the ceiling fans, not a handler any model is mapped
19
+ to. Every one of them - H1310, R1310 and H1370 - runs `fan-ceiling.js`, the
20
+ subclass, which reads the two lights and the fan correctly.
21
+
22
+ What is left here is the single-light fallback that subclass drops back to
23
+ when a device cannot reach its per-light api switches. Read it as "the least
24
+ this hardware can do", not as a description of the hardware.
25
+
26
+ ⚠️ The comment that used to sit here said `aa 36 01 01` was fan power on the
27
+ H1370. It is not: it is the two lights, on every model. Labelled captures on
28
+ #1358 settled it - switching the fan on left `aa 36` at `00 00` and moved
29
+ `aa 31` byte three instead. `internalStateUpdate` below still sends the light
30
+ frame, which is exactly why no model is mapped straight to this class.
31
+
32
+ Speeds differ - six on the H1310, twelve on the H1370 - so the scale comes
33
+ from the capabilities table (#1352)
27
34
  */
28
35
  export default class GoveeFanH1370 extends GoveeDevice {
29
36
  constructor(platform, accessory) {
@@ -142,7 +149,9 @@ export default class GoveeFanH1370 extends GoveeDevice {
142
149
  return
143
150
  }
144
151
 
145
- // Mirrors the fan power status code aa 36 01 01 (on) / aa 36 00 00 (off)
152
+ // ⚠️ `aa 36` is the two LIGHTS, not the fan (#1358). Kept only because a
153
+ // device without its api switches has nothing else to send; the subclass
154
+ // every model uses overrides this with the fan's own toggle
146
155
  const hexValues = value ? [0x33, 0x36, 0x01, 0x01] : [0x33, 0x36, 0x00, 0x00]
147
156
 
148
157
  // Send the request to the platform sender function
@@ -8,14 +8,23 @@ import {
8
8
  sleep,
9
9
  } from '../utils/functions.js'
10
10
  import platformLang from '../utils/lang-en.js'
11
- import deviceFanH1370 from './fan-H1370.js'
11
+ import deviceFanCeilingBase from './fan-H1370.js'
12
12
 
13
13
  /**
14
- * The two lights an H1310 has, the api switch each one answers to, and the bit
15
- * each one sets in the `aa 42` status.
14
+ * The ceiling fans with two lights: the H1310, its newer R1310 naming, and the
15
+ * H1370.
16
16
  *
17
- * Those switches are the only way to reach one light without the other -
18
- * `powerSwitch` is the whole unit, fan included.
17
+ * ⚠️ The H1370 was on the base handler until captures posted on #1358 settled
18
+ * that it speaks exactly the same language as the H1310 - `aa 36` is the two
19
+ * lights and `aa 31` byte three is the fan, on both. The base handler had it
20
+ * the other way round, so switching the fan on in HomeKit sent `33 36 01 01`
21
+ * and turned both lights on without ever reaching the fan.
22
+ *
23
+ * The H1370 additionally oscillates, which the H1310 does not.
24
+ *
25
+ * The two lights, the api switch each one answers to, and the bit each one
26
+ * sets in the `aa 42` status. Those switches are the only way to reach one
27
+ * light without the other - `powerSwitch` is the whole unit, fan included.
19
28
  */
20
29
  /**
21
30
  * ⚠️ The fan has ONE brightness and ONE colour, shared by both lights - the
@@ -67,6 +76,9 @@ const FAN_CODE = '31'
67
76
  const FAN_TOGGLE = 'fanToggle'
68
77
  const REVERSE_AIRFLOW = 'reverseAirflowToggle'
69
78
 
79
+ /** Oscillation, which the H1370 has and the H1310 does not. */
80
+ const OSCILLATE = 'fanOscillateToggle'
81
+
70
82
  /**
71
83
  * Reads which lights are lit out of a status, from whichever of the two frames
72
84
  * the fan sent, normalised to the `aa 42` mask.
@@ -111,7 +123,7 @@ function fanIsRunningFrom(commands) {
111
123
  return running
112
124
  }
113
125
 
114
- export default class GoveeFanH1310 extends deviceFanH1370 {
126
+ export default class GoveeFanCeiling extends deviceFanCeilingBase {
115
127
  constructor(platform, accessory) {
116
128
  super(platform, accessory)
117
129
 
@@ -131,7 +143,19 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
131
143
  this.canSendFanToggle = !!accessory.context.openApiCapabilities?.[FAN_TOGGLE]
132
144
  && !!accessory.context.useOpenApiControl
133
145
 
134
- if (this.service.testCharacteristic(this.hapChar.SwingMode)) {
146
+ // Oscillation. The H1310 cannot oscillate and the H1370 can, so this is
147
+ // gated on the capability rather than on the model - and anything left
148
+ // over on a fan that turns out not to have it is removed, which is what
149
+ // this handler already had to do for the tower fan's control (#1352)
150
+ this.canOscillate = !!accessory.context.openApiCapabilities?.[OSCILLATE]
151
+ && !!accessory.context.useOpenApiControl
152
+
153
+ if (this.canOscillate) {
154
+ this.service
155
+ .getCharacteristic(this.hapChar.SwingMode)
156
+ .onSet(async value => this.internalSwingUpdate(value))
157
+ this.cacheSwing = this.service.getCharacteristic(this.hapChar.SwingMode).value
158
+ } else if (this.service.testCharacteristic(this.hapChar.SwingMode)) {
135
159
  this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.SwingMode))
136
160
  }
137
161
 
@@ -263,6 +287,39 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
263
287
  }
264
288
  }
265
289
 
290
+ /**
291
+ * Oscillation on and off.
292
+ *
293
+ * The fan reports it in the eighth byte of its speed frame, but there is no
294
+ * frame known to SEND it, so this is api-only - which is why the control is
295
+ * gated on the capability being reachable rather than merely present.
296
+ *
297
+ * @param {number} value 1 swinging, 0 still
298
+ */
299
+ async internalSwingUpdate(value) {
300
+ try {
301
+ if (this.cacheSwing === value) {
302
+ return
303
+ }
304
+
305
+ await this.platform.sendDeviceUpdate(this.accessory, {
306
+ cmd: 'openApi',
307
+ openApi: {
308
+ instance: OSCILLATE,
309
+ capabilityType: 'devices.capabilities.toggle',
310
+ value: value === 1 ? 1 : 0,
311
+ },
312
+ })
313
+
314
+ this.cacheSwing = value
315
+ this.accessory.log(`current oscillation [${value === 1 ? 'on' : 'off'}]`)
316
+ } catch (err) {
317
+ this.failUpdate(err, () => {
318
+ this.service.updateCharacteristic(this.hapChar.SwingMode, this.cacheSwing)
319
+ })
320
+ }
321
+ }
322
+
266
323
  /**
267
324
  * The device-wide on/off field is the whole unit having power here, not the
268
325
  * light: a fan reporting `onOff` 1 has been seen with `aa 36 00` (fan off)
@@ -342,6 +399,30 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
342
399
  })
343
400
  }
344
401
 
402
+ // Oscillation, read off the eighth byte of the same speed frame. Proven
403
+ // against ekalp's labelled captures on #1358, where switching it off, on
404
+ // and off again was the only thing that byte did - `aa 31 01 04 00 00 00
405
+ // 01` swinging and `... 00` still, with every other byte unchanged
406
+ if (this.canOscillate) {
407
+ let swinging
408
+ ;(params.commands || []).forEach((command) => {
409
+ const hexParts = hexToTwoItems(base64ToHex(command))
410
+ if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === FAN_CODE) {
411
+ swinging = getTwoItemPosition(hexParts, 8) === '01' ? 1 : 0
412
+ }
413
+ })
414
+
415
+ if (swinging === undefined && params.toggles?.[OSCILLATE] !== undefined) {
416
+ swinging = params.toggles[OSCILLATE] ? 1 : 0
417
+ }
418
+
419
+ if (swinging !== undefined && this.cacheSwing !== swinging) {
420
+ this.cacheSwing = swinging
421
+ this.service.updateCharacteristic(this.hapChar.SwingMode, swinging)
422
+ this.accessory.log(`current oscillation [${swinging === 1 ? 'on' : 'off'}]`)
423
+ }
424
+ }
425
+
345
426
  // Airflow direction is a separate capability from the lights, so it gets
346
427
  // its own guard rather than sharing theirs
347
428
  if (!this.canReverseAirflow) {
@@ -10,8 +10,7 @@ import {
10
10
  import deviceCoolerSingle from './cooler-single.js'
11
11
  import deviceDehumidifierH7150 from './dehumidifier-H7150.js'
12
12
  import deviceDiffuserH7161 from './diffuser-H7161.js'
13
- import deviceFanH1310 from './fan-H1310.js'
14
- import deviceFanH1370 from './fan-H1370.js'
13
+ import deviceFanCeiling from './fan-ceiling.js'
15
14
  import deviceFanH7100 from './fan-H7100.js'
16
15
  import deviceFanH7105 from './fan-H7105.js'
17
16
  import deviceFanH7107 from './fan-H7107.js'
@@ -61,9 +60,12 @@ export default {
61
60
  deviceDehumidifierBasic,
62
61
  deviceDiffuserBasic,
63
62
  deviceFanBasic,
64
- deviceFanH1310,
65
- deviceFanH1370,
66
- deviceFanR1310: deviceFanH1310, // R1310 is the newer naming of the H1310
63
+ // The three ceiling fans all speak the same language - two lights on `aa 36`
64
+ // and the fan on `aa 31` - settled by the captures on #1358. The H1370 was
65
+ // on the base handler, which had those the other way round
66
+ deviceFanH1310: deviceFanCeiling,
67
+ deviceFanH1370: deviceFanCeiling,
68
+ deviceFanR1310: deviceFanCeiling, // R1310 is the newer naming of the H1310
67
69
  deviceFanH7100,
68
70
  deviceFanH7102: deviceFanH7100, // identical to the H7100
69
71
  deviceFanH7106: deviceFanH7100, // identical to the H7100
@@ -503,14 +503,53 @@ export default class extends GoveeDevice {
503
503
  }
504
504
 
505
505
  switch (deviceFunction) {
506
- case '0500': { // current mode
506
+ case '0500': { // which slot the target temperature comes from
507
+ // Settled by two owner runs in #1337, both with the kettle at 212°F
508
+ // so that only the control used differed:
509
+ //
510
+ // aa 05 00 02 after tapping the first memory preset in the app
511
+ // aa 05 00 01 after setting 212°F by hand with the slider
512
+ //
513
+ // So the byte points at the temperature slot the target comes from:
514
+ // 01 is the custom target slot, 02-05 are the memory presets - the
515
+ // same numbering as the aa 05 <slot> frames above. And it tracks
516
+ // which CONTROL was used, not the number: 212°F set by slider still
517
+ // reads slot 1, even though the first preset holds exactly 212°F.
518
+ //
519
+ // Two honest gaps. Slots 03-05 are inferred from the pattern - only
520
+ // 01 and 02 have been seen. And the switch-command examples below,
521
+ // which predate those runs, number the drink modes differently
522
+ // (green tea 02, oolong 03...); both cannot be right for this
523
+ // kettle, so they are kept only until a mode-switch run settles it:
524
+ //
507
525
  // switch to green tea_: aa050002000000000000000000000000000000ad
508
526
  // switch to oolong tea: aa050003000000000000000000000000000000ac
509
527
  // switch to coffee____: aa050004000000000000000000000000000000ab
510
528
  // switch to black tea_: aa050005000000000000000000000000000000aa
511
529
  // switch to preset1___: aa050001010000000000000000000000000000af
512
530
  // switch to preset2___: aa050001020000000000000000000000000000ac
513
- // No point in changing any switches for the sake of three seconds, no action taken here
531
+ const slot = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
532
+ if (slot < 1 || slot > 5) {
533
+ logUnknownData(this.accessory, {
534
+ kind: 'status',
535
+ source: 'AWS',
536
+ raw: hexString,
537
+ hex: hexParts.join(''),
538
+ extra: { note: 'aa 05 00 frame pointing at a slot the kettle does not have' },
539
+ })
540
+ break
541
+ }
542
+ const line = slot === 1
543
+ ? 'target temperature set from [custom temperature]'
544
+ : `target temperature set from [memory preset ${slot - 1}]`
545
+ // The kettle repeats this in every startup burst - a change is worth
546
+ // a normal log line, a repeat is not
547
+ if (this.cacheTargetSlot === slot) {
548
+ this.accessory.logDebug(line)
549
+ } else {
550
+ this.cacheTargetSlot = slot
551
+ this.accessory.log(line)
552
+ }
514
553
  break
515
554
  }
516
555
  case '1001': { // current temperature of the water
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.37.1",
5
+ "version": "11.38.0-beta.1",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",