@homebridge-plugins/homebridge-govee 11.36.1-beta.9 → 11.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
- ## v11.36.1 (Pending Release)
5
+ ## v11.37.0 (2026-08-14)
6
6
 
7
7
  ### Changed
8
8
 
@@ -18,6 +18,19 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
18
18
  - fix: only give the H1310 and R1310 two light tiles when each has a switch of its own to send (#1352)
19
19
  - fix: lead a thermo sensor's tile with its temperature, not its humidity, when shown as a thermostat (#1354)
20
20
  - docs(schema): describe what the thermo sensor extra switch setting actually does now (#1354)
21
+ - feat: let a ceiling fan that can reverse be switched between forward and reverse from the fan tile (#1352)
22
+ - fix: stop calling a ceiling fan's speed report unrecognised while the fan is off (#1352)
23
+ - fix: read a ceiling fan's airflow direction from what the fan reports, so the tile follows the govee app (#1352)
24
+ - fix: show each of a ceiling fan's two lights as on or off from what the fan reports (#1352)
25
+ - fix: lead a ceiling fan's tile with the fan rather than one of its lights (#1352)
26
+ - fix: read a ceiling fan's light from what it says about its lights, not from whether the fan has power (#1352)
27
+ - fix: stop calling a ceiling fan's light status unrecognised on a handler that reads it (#1352)
28
+ - fix: stop reporting govee's command receipts as messages the plugin did not understand (#1352)
29
+ - fix: read whether a ceiling fan is turning from the fan itself, not from its lights (#1352)
30
+ - fix: switch a ceiling fan on with its own switch, not with a command that turns its lights on (#1352)
31
+ - fix: put a ceiling fan's shared brightness and colour on one light tile, as the fan has only one of each (#1352)
32
+ - fix: switch a ceiling fan's light off instead of dimming both its lights to nothing (#1352)
33
+ - chore(deps): dependency updates
21
34
 
22
35
  ## v11.36.0 (2026-08-11)
23
36
 
@@ -1,17 +1,116 @@
1
1
  import { hs2rgb } from '../utils/colour.js'
2
- import { generateRandomString, sleep } from '../utils/functions.js'
2
+ import {
3
+ base64ToHex,
4
+ generateCodeFromHexValues,
5
+ generateRandomString,
6
+ getTwoItemPosition,
7
+ hexToTwoItems,
8
+ sleep,
9
+ } from '../utils/functions.js'
10
+ import platformLang from '../utils/lang-en.js'
3
11
  import deviceFanH1370 from './fan-H1370.js'
4
12
 
5
13
  /**
6
- * The two lights an H1310 has, and the api switch each one answers to. Govee
7
- * lists both against this device, and they are the only way to reach one light
8
- * without the other - `powerSwitch` is the whole unit, fan included.
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.
16
+ *
17
+ * Those switches are the only way to reach one light without the other -
18
+ * `powerSwitch` is the whole unit, fan included.
19
+ */
20
+ /**
21
+ * ⚠️ The fan has ONE brightness and ONE colour, shared by both lights - the
22
+ * govee app offers a single set too. So only the main light carries them, and
23
+ * the background light is an on/off tile.
24
+ *
25
+ * Two sliders that secretly move together read as a fault, but the real cost
26
+ * was worse: HomeKit sends a brightness of 0 when a bulb is switched off, and
27
+ * the brightness command is device-wide, so switching one light off dimmed the
28
+ * pair to nothing (#1352).
9
29
  */
10
30
  const LIGHTS = [
11
- { name: 'Main Light', instance: 'mainLightToggle' },
12
- { name: 'Background Light', instance: 'backgroundLightToggle' },
31
+ { name: 'Main Light', instance: 'mainLightToggle', bit: 0x40, carriesSharedControls: true },
32
+ { name: 'Background Light', instance: 'backgroundLightToggle', bit: 0x20, carriesSharedControls: false },
13
33
  ]
14
34
 
35
+ /**
36
+ * The two status frames carrying the lights. This fan sends both, every time,
37
+ * saying the same thing two different ways:
38
+ *
39
+ * - `aa 42 <mask>` - one byte, bit 6 main, bit 5 background, bit 7 either
40
+ * - `aa 36 <main> <background>` - one byte each
41
+ *
42
+ * They agree in all 69 statuses posted on #1352:
43
+ *
44
+ * | `aa 36` | `aa 42` | lit |
45
+ * |---------|---------|--------------------|
46
+ * | `01 01` | `e0` | both |
47
+ * | `01 00` | `c0` | main only |
48
+ * | `00 01` | `a0` | background only |
49
+ * | `00 00` | `00` | neither |
50
+ *
51
+ * ⚠️ `aa 36` is fan power on the H1370 and the lights here, which is why this
52
+ * handler has to claim the code rather than let the parent read it.
53
+ */
54
+ const LIGHT_MASK_CODE = '42'
55
+ const LIGHT_PAIR_CODE = '36'
56
+
57
+ /** Set in that mask whenever either light is lit, whichever one it is. */
58
+ const ANY_LIGHT_BIT = 0x80
59
+
60
+ /**
61
+ * The speed frame, which also carries whether the fan is turning:
62
+ * `aa 31 <running> <speed> <direction>`.
63
+ */
64
+ const FAN_CODE = '31'
65
+
66
+ /** The api switches for the fan itself and for running it backwards. */
67
+ const FAN_TOGGLE = 'fanToggle'
68
+ const REVERSE_AIRFLOW = 'reverseAirflowToggle'
69
+
70
+ /**
71
+ * Reads which lights are lit out of a status, from whichever of the two frames
72
+ * the fan sent, normalised to the `aa 42` mask.
73
+ *
74
+ * @param {string[]} commands base64 status codes from the fan
75
+ * @returns {number|undefined} the mask, or undefined if the fan said neither
76
+ */
77
+ function lightMaskFrom(commands) {
78
+ let mask
79
+ ;(commands || []).forEach((command) => {
80
+ const hexParts = hexToTwoItems(base64ToHex(command))
81
+ if (getTwoItemPosition(hexParts, 1) !== 'aa') {
82
+ return
83
+ }
84
+ if (getTwoItemPosition(hexParts, 2) === LIGHT_MASK_CODE) {
85
+ mask = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
86
+ return
87
+ }
88
+ if (getTwoItemPosition(hexParts, 2) === LIGHT_PAIR_CODE) {
89
+ const main = getTwoItemPosition(hexParts, 3) === '01'
90
+ const background = getTwoItemPosition(hexParts, 4) === '01'
91
+ mask = (main ? 0x40 : 0) | (background ? 0x20 : 0) | (main || background ? ANY_LIGHT_BIT : 0)
92
+ }
93
+ })
94
+ return mask
95
+ }
96
+
97
+ /**
98
+ * Reads whether the fan is turning, from the third byte of its speed frame.
99
+ *
100
+ * @param {string[]} commands base64 status codes from the fan
101
+ * @returns {boolean|undefined} true if turning, or undefined if the fan did not say
102
+ */
103
+ function fanIsRunningFrom(commands) {
104
+ let running
105
+ ;(commands || []).forEach((command) => {
106
+ const hexParts = hexToTwoItems(base64ToHex(command))
107
+ if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === FAN_CODE) {
108
+ running = getTwoItemPosition(hexParts, 3) === '01'
109
+ }
110
+ })
111
+ return running
112
+ }
113
+
15
114
  export default class GoveeFanH1310 extends deviceFanH1370 {
16
115
  constructor(platform, accessory) {
17
116
  super(platform, accessory)
@@ -20,10 +119,42 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
20
119
  // ceiling-fan range. (speedSteps now comes from device-capabilities or the
21
120
  // device's OpenAPI fanSpeedMode; do not overwrite the superclass value here.)
22
121
 
122
+ // Both light frames are read here. `aa 36` especially has to be claimed:
123
+ // the parent reads it as fan power, so left to it the fan tile followed
124
+ // the lights - off whenever they were off, whatever the fan was doing
125
+ this.handledStatusCodes.add(LIGHT_MASK_CODE)
126
+ this.handledStatusCodes.add(LIGHT_PAIR_CODE)
127
+
128
+ // The fan has its own api switch, which is the only way to reach the fan
129
+ // without touching the lights. `33 36 ...`, which the parent sends for fan
130
+ // power, is the two lights on this model
131
+ this.canSendFanToggle = !!accessory.context.openApiCapabilities?.[FAN_TOGGLE]
132
+ && !!accessory.context.useOpenApiControl
133
+
23
134
  if (this.service.testCharacteristic(this.hapChar.SwingMode)) {
24
135
  this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.SwingMode))
25
136
  }
26
137
 
138
+ // Airflow direction. HomeKit's fan already has somewhere to put this, so it
139
+ // goes on the tile the owner already has rather than adding another one.
140
+ //
141
+ // Gated on its own capability rather than on the lights below: a fan could
142
+ // reasonably have one and not the other, and a control that cannot be sent
143
+ // is worse than no control (#1352)
144
+ this.canReverseAirflow = !!accessory.context.openApiCapabilities?.[REVERSE_AIRFLOW]
145
+ && !!accessory.context.useOpenApiControl
146
+
147
+ if (this.canReverseAirflow) {
148
+ this.service
149
+ .getCharacteristic(this.hapChar.RotationDirection)
150
+ .onSet(async value => this.internalDirectionUpdate(value))
151
+ this.cacheDirection = this.service.getCharacteristic(this.hapChar.RotationDirection).value
152
+ } else if (this.service.testCharacteristic(this.hapChar.RotationDirection)) {
153
+ // Left over from a device that used to report the switch, or from a
154
+ // config that has since lost api access
155
+ this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.RotationDirection))
156
+ }
157
+
27
158
  // Two tiles are only honest when each one has a switch of its own to send.
28
159
  // Those switches exist solely over the api, so without it both tiles would
29
160
  // fall back to the device-wide power switch - and either would take the fan
@@ -46,19 +177,227 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
46
177
  }
47
178
 
48
179
  this.lightServices = {}
49
- LIGHTS.forEach(({ name }) => {
180
+ LIGHTS.forEach(({ name, carriesSharedControls }) => {
50
181
  const service = this.accessory.getService(name)
51
182
  || this.accessory.addService(this.hapServ.Lightbulb, name, name)
52
183
  this.lightServices[name] = service
53
- this.setupLightService(service, name)
184
+ this.setupLightService(service, name, carriesSharedControls)
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Switching the fan on and off.
190
+ *
191
+ * The parent sends `33 36 01 01`, which on an H1370 is its fan. Here `aa 36`
192
+ * is the two lights, so that command turns both lights on and never touches
193
+ * the fan. The fan's own api switch is used instead where it can be sent,
194
+ * and otherwise the speed frame - `33 31 <running> <speed>` - which is the
195
+ * frame this fan reports its own running state in (#1352).
196
+ *
197
+ * @param {number} value 1 active, 0 inactive
198
+ */
199
+ async internalStateUpdate(value) {
200
+ try {
201
+ const newValue = value ? 'on' : 'off'
202
+ if (this.cacheState === newValue) {
203
+ return
204
+ }
205
+
206
+ if (this.canSendFanToggle) {
207
+ await this.platform.sendDeviceUpdate(this.accessory, {
208
+ cmd: 'openApi',
209
+ openApi: {
210
+ instance: FAN_TOGGLE,
211
+ capabilityType: 'devices.capabilities.toggle',
212
+ value: value ? 1 : 0,
213
+ },
214
+ })
215
+ } else {
216
+ // Keep the speed the fan already had, so switching it back on returns
217
+ // it to where its owner left it rather than to a speed of nothing
218
+ const speed = this.cacheSpeed || 1
219
+ await this.platform.sendDeviceUpdate(this.accessory, {
220
+ cmd: 'ptReal',
221
+ value: generateCodeFromHexValues([0x33, 0x31, value ? 0x01 : 0x00, speed]),
222
+ })
223
+ }
224
+
225
+ this.cacheState = newValue
226
+ this.accessory.log(`${platformLang.curState} [${newValue}]`)
227
+ } catch (err) {
228
+ this.failUpdate(err, () => {
229
+ this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
230
+ })
231
+ }
232
+ }
233
+
234
+ /**
235
+ * HomeKit gives a fan two directions and Govee gives it one switch, so the
236
+ * two map onto each other directly. Which way round the fan physically turns
237
+ * for "reverse" is Govee's business and is not written down anywhere - if an
238
+ * owner reports the two being the wrong way round, swap them here.
239
+ *
240
+ * @param {number} value 0 clockwise, 1 counter-clockwise
241
+ */
242
+ async internalDirectionUpdate(value) {
243
+ try {
244
+ if (this.cacheDirection === value) {
245
+ return
246
+ }
247
+
248
+ await this.platform.sendDeviceUpdate(this.accessory, {
249
+ cmd: 'openApi',
250
+ openApi: {
251
+ instance: REVERSE_AIRFLOW,
252
+ capabilityType: 'devices.capabilities.toggle',
253
+ value: value === 1 ? 1 : 0,
254
+ },
255
+ })
256
+
257
+ this.cacheDirection = value
258
+ this.accessory.log(`${platformLang.curDirection} [${value === 1 ? 'upward' : 'downward'}]`)
259
+ } catch (err) {
260
+ this.failUpdate(err, () => {
261
+ this.service.updateCharacteristic(this.hapChar.RotationDirection, this.cacheDirection)
262
+ })
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The device-wide on/off field is the whole unit having power here, not the
268
+ * light: a fan reporting `onOff` 1 has been seen with `aa 36 00` (fan off)
269
+ * and `aa 42 00` (both lights off) in the same status. Letting it speak for
270
+ * the light lit the light tile every time the fan was switched on (#1352).
271
+ *
272
+ * The `aa 42` mask is what this fan actually says about its lights, so read
273
+ * the single tile off the bit that is set whenever either one is lit.
274
+ *
275
+ * @param {object} params a parsed device update
276
+ * @returns {'on'|'off'|undefined} the light's state, or undefined if unsaid
277
+ */
278
+ readLightState(params) {
279
+ const mask = lightMaskFrom(params.commands)
280
+ if (mask === undefined) {
281
+ return undefined
282
+ }
283
+ return (mask & ANY_LIGHT_BIT) === ANY_LIGHT_BIT ? 'on' : 'off'
284
+ }
285
+
286
+ externalUpdate(params) {
287
+ super.externalUpdate(params)
288
+
289
+ // Whether the fan is turning. The parent cannot read this: on an H1370 it
290
+ // comes from `aa 36`, which here belongs to the lights. This fan says so in
291
+ // the third byte of its speed frame instead, and the api switch is read as
292
+ // a fallback for a device answering over the api rather than aws (#1352)
293
+ let running = fanIsRunningFrom(params.commands)
294
+ if (running === undefined && params.toggles?.[FAN_TOGGLE] !== undefined) {
295
+ running = !!params.toggles[FAN_TOGGLE]
296
+ }
297
+ if (running !== undefined) {
298
+ const newState = running ? 'on' : 'off'
299
+ if (this.cacheState !== newState) {
300
+ this.cacheState = newState
301
+ this.service.updateCharacteristic(this.hapChar.Active, running ? 1 : 0)
302
+ this.accessory.log(`${platformLang.curState} [${newState}]`)
303
+ }
304
+ }
305
+
306
+ // Each light's own on/off, read off the same mask
307
+ const mask = lightMaskFrom(params.commands)
308
+ if (mask !== undefined) {
309
+ LIGHTS.forEach(({ name, bit }) => {
310
+ const service = this.lightServices?.[name]
311
+ if (!service) {
312
+ return
313
+ }
314
+ const newValue = (mask & bit) === bit ? 'on' : 'off'
315
+ const cacheKey = `${name}:state`
316
+ if (this[cacheKey] !== newValue) {
317
+ this[cacheKey] = newValue
318
+ service.updateCharacteristic(this.hapChar.On, newValue === 'on')
319
+ this.accessory.log(`${name} ${newValue}`)
320
+ }
321
+ })
322
+ }
323
+
324
+ // The one shared brightness and colour, reported onto the one tile that
325
+ // carries them. The parent puts these on its single light, which the named
326
+ // tiles replaced
327
+ if (this.lightServices) {
328
+ LIGHTS.filter(light => light.carriesSharedControls).forEach(({ name }) => {
329
+ const service = this.lightServices[name]
330
+ if (params.brightness && this[`${name}:brightness`] !== params.brightness) {
331
+ this[`${name}:brightness`] = params.brightness
332
+ service.updateCharacteristic(this.hapChar.Brightness, params.brightness)
333
+ }
334
+ if (params.kelvin) {
335
+ const k = Math.min(Math.max(params.kelvin, 2700), 6500)
336
+ const mired = Math.min(Math.max(Math.round(1000000 / k), 154), 370)
337
+ if (this[`${name}:ct`] !== mired) {
338
+ this[`${name}:ct`] = mired
339
+ service.updateCharacteristic(this.hapChar.ColorTemperature, mired)
340
+ }
341
+ }
342
+ })
343
+ }
344
+
345
+ // Airflow direction is a separate capability from the lights, so it gets
346
+ // its own guard rather than sharing theirs
347
+ if (!this.canReverseAirflow) {
348
+ return
349
+ }
350
+
351
+ // The fan reports its direction as the fifth byte of its speed frame -
352
+ // `aa 31 <running> <speed> <direction>` - rather than as a status of its
353
+ // own. Captured on a real H1310 in #1352: flowing downward reported
354
+ // `aa 31 01 01 00` and upward `aa 31 01 01 01`, with nothing else in the
355
+ // status differing between the two.
356
+ let reported
357
+ ;(params.commands || []).forEach((command) => {
358
+ const hexParts = hexToTwoItems(base64ToHex(command))
359
+ if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === '31') {
360
+ reported = getTwoItemPosition(hexParts, 5) === '01' ? 1 : 0
361
+ }
54
362
  })
363
+
364
+ // The api toggle is the only way to SEND a direction, so it is still read
365
+ // as a fallback for a device answering over the api rather than aws
366
+ if (reported === undefined && params.toggles?.[REVERSE_AIRFLOW] !== undefined) {
367
+ reported = params.toggles[REVERSE_AIRFLOW] ? 1 : 0
368
+ }
369
+
370
+ if (reported !== undefined && this.cacheDirection !== reported) {
371
+ this.cacheDirection = reported
372
+ this.service.updateCharacteristic(this.hapChar.RotationDirection, reported)
373
+ this.accessory.log(`${platformLang.curDirection} [${reported === 1 ? 'upward' : 'downward'}]`)
374
+ }
55
375
  }
56
376
 
57
- setupLightService(service, name) {
377
+ setupLightService(service, name, carriesSharedControls) {
58
378
  service.getCharacteristic(this.hapChar.On).onSet(async (value) => {
59
379
  await this.internalLightStateUpdate(service, name, value)
60
380
  })
61
381
 
382
+ if (!carriesSharedControls) {
383
+ // An on/off tile. The brightness and colour live on the other light, as
384
+ // the fan has only one of each. Anything left over from a cached version
385
+ // that gave both tiles the full set has to go, or HomeKit keeps showing
386
+ // a slider that moves the other light too
387
+ const shared = [
388
+ this.hapChar.Brightness,
389
+ this.hapChar.Hue,
390
+ this.hapChar.Saturation,
391
+ this.hapChar.ColorTemperature,
392
+ ]
393
+ shared.forEach((characteristic) => {
394
+ if (service.testCharacteristic(characteristic)) {
395
+ service.removeCharacteristic(service.getCharacteristic(characteristic))
396
+ }
397
+ })
398
+ return
399
+ }
400
+
62
401
  service
63
402
  .getCharacteristic(this.hapChar.Brightness)
64
403
  .setProps({ minStep: 1 })
@@ -113,6 +452,15 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
113
452
  return
114
453
  }
115
454
 
455
+ // HomeKit sends a brightness of 0 when a bulb is switched off, and the
456
+ // brightness command is device-wide - so obeying it dimmed BOTH lights to
457
+ // nothing. Switch this light off with its own switch instead, and leave
458
+ // the shared brightness where its owner had it (#1352)
459
+ if (value === 0) {
460
+ await this.internalLightStateUpdate(service, name, false)
461
+ return
462
+ }
463
+
116
464
  const cacheKey = `${name}:brightness`
117
465
  if (this[cacheKey] === value) {
118
466
  return
@@ -17,7 +17,7 @@ import GoveeDevice from './base.js'
17
17
  /*
18
18
  Codes below were decoded from a real device's AWS status reports in
19
19
  https://github.com/homebridge-plugins/homebridge-govee/issues/1307
20
- Status aa 31 01 [speed] = fan speed (1 to the model's step count)
20
+ Status aa 31 [?] [speed] [direction] = fan speed and airflow direction
21
21
  Status aa 36 01 01 / aa 36 00 00 = fan power on/off
22
22
  The light reports through the standard state/brightness/kelvin fields
23
23
  (colour temperature only, 2700-6500K)
@@ -42,9 +42,20 @@ export default class GoveeFanH1370 extends GoveeDevice {
42
42
  }
43
43
  }
44
44
 
45
+ // Status codes a subclass reads for itself. Without this the parent still
46
+ // reaches its default branch and announces a code that was in fact handled,
47
+ // which is the same unrecognised-status noise this model was reported for
48
+ this.handledStatusCodes = new Set()
49
+
45
50
  // Add the fan service if it doesn't already exist
46
51
  this.service = this.accessory.getService(this.hapServ.Fanv2) || this.accessory.addService(this.hapServ.Fanv2)
47
52
 
53
+ // This accessory is a fan that happens to have lights on it, so the fan is
54
+ // what its tile should lead with. Without saying so the Home app picks for
55
+ // itself and lands on a light, leaving an owner tapping through to reach
56
+ // the fan they came for (#1352)
57
+ this.service.setPrimaryService(true)
58
+
48
59
  // Add the set handler to the fan on/off characteristic
49
60
  this.service
50
61
  .getCharacteristic(this.hapChar.Active)
@@ -281,11 +292,29 @@ export default class GoveeFanH1370 extends GoveeDevice {
281
292
  }
282
293
  }
283
294
 
295
+ /**
296
+ * Whether the light is lit, as this model reports it.
297
+ *
298
+ * On the H1370 the device-wide on/off field is the only word there is on the
299
+ * light, so it is taken at face value. A model that says so separately should
300
+ * override this rather than let that field speak for the light - see the
301
+ * H1310, where it stays on while both lights are off.
302
+ *
303
+ * @param {object} params a parsed device update
304
+ * @returns {'on'|'off'|undefined} the light's state, or undefined if unsaid
305
+ */
306
+ readLightState(params) {
307
+ return params.state
308
+ }
309
+
284
310
  externalUpdate(params) {
285
- // The standard state/brightness/kelvin fields report the light
286
- if (!this.hideLight) {
287
- if (params.state && params.state !== this.cacheLightState) {
288
- this.cacheLightState = params.state
311
+ // The standard state/brightness/kelvin fields report the light. A subclass
312
+ // that replaces the single light with named ones leaves lightService unset,
313
+ // and updating it would throw on every status the fan sends
314
+ if (!this.hideLight && this.lightService) {
315
+ const lightState = this.readLightState(params)
316
+ if (lightState && lightState !== this.cacheLightState) {
317
+ this.cacheLightState = lightState
289
318
  this.lightService.updateCharacteristic(this.hapChar.On, this.cacheLightState === 'on')
290
319
  this.accessory.log(`${platformLang.curLight} [${this.cacheLightState}]`)
291
320
  }
@@ -316,9 +345,24 @@ export default class GoveeFanH1370 extends GoveeDevice {
316
345
  return
317
346
  }
318
347
 
348
+ // A subclass that reads a code for itself owns it outright, so the parent
349
+ // must neither act on it nor call it unrecognised. The same code can mean
350
+ // different things on different fans - `aa 36` is fan power on an H1370
351
+ // and the two lights on an H1310 - so acting on it here would be wrong
352
+ if (this.handledStatusCodes.has(getTwoItemPosition(hexParts, 2))) {
353
+ return
354
+ }
355
+
319
356
  const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
320
357
 
321
358
  switch (deviceFunction) {
359
+ // aa 31 <?> <speed> <direction>. The third byte is not part of the
360
+ // code: an H1310 has been seen reporting both 00 and 01 for it with the
361
+ // fan running either way, so what it means is still unknown - but
362
+ // matching only 3101 announced every other status as unrecognised,
363
+ // which is the noise that sent this model's owner here (#1352).
364
+ // `aa 36` is the reliable word on whether the fan is running.
365
+ case '3100':
322
366
  case '3101': {
323
367
  // Fan speed
324
368
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
package/lib/platform.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  } from './utils/functions.js'
31
31
  import platformLang from './utils/lang-en.js'
32
32
  import { isFirstReport, logUnknownData } from './utils/report-unknown.js'
33
- import { parseDeviceUpdate } from './utils/response-parser.js'
33
+ import { isCommandAcknowledgement, parseDeviceUpdate } from './utils/response-parser.js'
34
34
 
35
35
  const require = createRequire(import.meta.url)
36
36
  const plugin = require('../package.json')
@@ -1422,6 +1422,11 @@ export default class {
1422
1422
  } catch (err) {
1423
1423
  this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotUpdated, parseError(err))
1424
1424
  }
1425
+ } else if (isCommandAcknowledgement(params)) {
1426
+ // Govee answers a command with a receipt carrying nothing but whether it
1427
+ // worked. There is no status in it to miss, so reporting it as a payload
1428
+ // nobody understood buries the ones that are worth reading (#1352)
1429
+ accessory.logDebug(`[${params.source}] ${platformLang.cmdAcknowledged} [${params.cmd}]`)
1425
1430
  } else {
1426
1431
  // Nothing in the payload was understood. This is the most useful thing a
1427
1432
  // user can send in when asking for a device or a function to be added, so
@@ -65,6 +65,7 @@ export default {
65
65
  curOcc: 'current occupancy',
66
66
  curPM25: 'current PM2.5',
67
67
  curScene: 'current scene',
68
+ curDirection: 'current airflow direction',
68
69
  curSpeed: 'current speed',
69
70
  curState: 'current state',
70
71
  curSwing: 'current swing',
@@ -140,4 +141,5 @@ export default {
140
141
  syncStillRunning: 'skipping this sync as the previous one has not finished yet',
141
142
  viaAL: 'via adaptive lighting',
142
143
  statusCodes: 'device status codes',
144
+ cmdAcknowledged: 'command acknowledged',
143
145
  }
@@ -47,6 +47,24 @@ function resolveOpenApiTempUnit(value, context) {
47
47
  return null
48
48
  }
49
49
 
50
+ /**
51
+ * Whether a payload is only govee's receipt for a command we sent.
52
+ *
53
+ * These carry a command name and a `result` and nothing else - no status at
54
+ * all - so there is nothing in them to have failed to understand. Anything
55
+ * that names even one other field is a real payload and is not this.
56
+ *
57
+ * @param {object} params - Raw payload with source, cmd, state, etc.
58
+ * @returns {boolean} true if the payload is a bare acknowledgement
59
+ */
60
+ export function isCommandAcknowledgement(params) {
61
+ if (!params?.cmd || !params.state || typeof params.state !== 'object') {
62
+ return false
63
+ }
64
+ const keys = Object.keys(params.state)
65
+ return keys.length > 0 && keys.every(key => key === 'result')
66
+ }
67
+
50
68
  /**
51
69
  * Parses a raw device update payload into a standardised data object.
52
70
  * Handles different payload structures from AWS, LAN, OpenAPI, and legacy devices.
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.36.1-beta.9",
5
+ "version": "11.37.0",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",