@homebridge-plugins/homebridge-govee 11.36.1-beta.13 → 11.36.1-beta.15

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
@@ -22,6 +22,10 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
22
22
  - fix: stop calling a ceiling fan's speed report unrecognised while the fan is off (#1352)
23
23
  - fix: read a ceiling fan's airflow direction from what the fan reports, so the tile follows the govee app (#1352)
24
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)
25
29
 
26
30
  ## v11.36.0 (2026-08-11)
27
31
 
@@ -30,9 +30,29 @@ const LIGHTS = [
30
30
  /** The status frame carrying that mask: `aa 42 <mask>`. */
31
31
  const LIGHT_MASK_CODE = '42'
32
32
 
33
+ /** Set in that mask whenever either light is lit, whichever one it is. */
34
+ const ANY_LIGHT_BIT = 0x80
35
+
33
36
  /** The api switch that runs the fan backwards. */
34
37
  const REVERSE_AIRFLOW = 'reverseAirflowToggle'
35
38
 
39
+ /**
40
+ * Reads the `aa 42 <mask>` light mask out of a status, if it is in there.
41
+ *
42
+ * @param {string[]} commands base64 status codes from the fan
43
+ * @returns {number|undefined} the mask, or undefined if the fan did not send one
44
+ */
45
+ function lightMaskFrom(commands) {
46
+ let mask
47
+ ;(commands || []).forEach((command) => {
48
+ const hexParts = hexToTwoItems(base64ToHex(command))
49
+ if (getTwoItemPosition(hexParts, 1) === 'aa' && getTwoItemPosition(hexParts, 2) === LIGHT_MASK_CODE) {
50
+ mask = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
51
+ }
52
+ })
53
+ return mask
54
+ }
55
+
36
56
  export default class GoveeFanH1310 extends deviceFanH1370 {
37
57
  constructor(platform, accessory) {
38
58
  super(platform, accessory)
@@ -41,6 +61,10 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
41
61
  // ceiling-fan range. (speedSteps now comes from device-capabilities or the
42
62
  // device's OpenAPI fanSpeedMode; do not overwrite the superclass value here.)
43
63
 
64
+ // This handler reads the light mask itself, so the parent should stop
65
+ // announcing it as a status nobody understood
66
+ this.handledStatusCodes.add(LIGHT_MASK_CODE)
67
+
44
68
  if (this.service.testCharacteristic(this.hapChar.SwingMode)) {
45
69
  this.service.removeCharacteristic(this.service.getCharacteristic(this.hapChar.SwingMode))
46
70
  }
@@ -127,16 +151,32 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
127
151
  }
128
152
  }
129
153
 
154
+ /**
155
+ * The device-wide on/off field is the whole unit having power here, not the
156
+ * light: a fan reporting `onOff` 1 has been seen with `aa 36 00` (fan off)
157
+ * and `aa 42 00` (both lights off) in the same status. Letting it speak for
158
+ * the light lit the light tile every time the fan was switched on (#1352).
159
+ *
160
+ * The `aa 42` mask is what this fan actually says about its lights, so read
161
+ * the single tile off the bit that is set whenever either one is lit.
162
+ *
163
+ * @param {object} params a parsed device update
164
+ * @returns {'on'|'off'|undefined} the light's state, or undefined if unsaid
165
+ */
166
+ readLightState(params) {
167
+ const mask = lightMaskFrom(params.commands)
168
+ if (mask === undefined) {
169
+ return undefined
170
+ }
171
+ return (mask & ANY_LIGHT_BIT) === ANY_LIGHT_BIT ? 'on' : 'off'
172
+ }
173
+
130
174
  externalUpdate(params) {
131
175
  super.externalUpdate(params)
132
176
 
133
177
  // Each light's own on/off, read off the same mask
134
- ;(params.commands || []).forEach((command) => {
135
- const hexParts = hexToTwoItems(base64ToHex(command))
136
- if (getTwoItemPosition(hexParts, 1) !== 'aa' || getTwoItemPosition(hexParts, 2) !== LIGHT_MASK_CODE) {
137
- return
138
- }
139
- const mask = Number.parseInt(getTwoItemPosition(hexParts, 3), 16)
178
+ const mask = lightMaskFrom(params.commands)
179
+ if (mask !== undefined) {
140
180
  LIGHTS.forEach(({ name, bit }) => {
141
181
  const service = this.lightServices?.[name]
142
182
  if (!service) {
@@ -150,7 +190,28 @@ export default class GoveeFanH1310 extends deviceFanH1370 {
150
190
  this.accessory.log(`${name} ${newValue}`)
151
191
  }
152
192
  })
153
- })
193
+ }
194
+
195
+ // The fan has one brightness and one colour shared between both lights, so
196
+ // a reading of either belongs on both tiles. The parent puts these on its
197
+ // single light, which the named tiles replaced
198
+ if (this.lightServices) {
199
+ LIGHTS.forEach(({ name }) => {
200
+ const service = this.lightServices[name]
201
+ if (params.brightness && this[`${name}:brightness`] !== params.brightness) {
202
+ this[`${name}:brightness`] = params.brightness
203
+ service.updateCharacteristic(this.hapChar.Brightness, params.brightness)
204
+ }
205
+ if (params.kelvin) {
206
+ const k = Math.min(Math.max(params.kelvin, 2700), 6500)
207
+ const mired = Math.min(Math.max(Math.round(1000000 / k), 154), 370)
208
+ if (this[`${name}:ct`] !== mired) {
209
+ this[`${name}:ct`] = mired
210
+ service.updateCharacteristic(this.hapChar.ColorTemperature, mired)
211
+ }
212
+ }
213
+ })
214
+ }
154
215
 
155
216
  // Airflow direction is a separate capability from the lights, so it gets
156
217
  // its own guard rather than sharing theirs
@@ -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 [running] [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
  }
@@ -319,11 +348,12 @@ export default class GoveeFanH1370 extends GoveeDevice {
319
348
  const deviceFunction = `${getTwoItemPosition(hexParts, 2)}${getTwoItemPosition(hexParts, 3)}`
320
349
 
321
350
  switch (deviceFunction) {
322
- // aa 31 <running> <speed>. The third byte is the fan's running flag,
323
- // not part of the code - a stopped fan reports 3100 and still names the
324
- // speed it will return to. Reading only 3101 meant that every status
325
- // sent while the fan was off was announced as unrecognised, which is
326
- // the noise that sent this model's owner to the issue tracker (#1352)
351
+ // aa 31 <?> <speed> <direction>. The third byte is not part of the
352
+ // code: an H1310 has been seen reporting both 00 and 01 for it with the
353
+ // fan running either way, so what it means is still unknown - but
354
+ // matching only 3101 announced every other status as unrecognised,
355
+ // which is the noise that sent this model's owner here (#1352).
356
+ // `aa 36` is the reliable word on whether the fan is running.
327
357
  case '3100':
328
358
  case '3101': {
329
359
  // Fan speed
@@ -355,6 +385,9 @@ export default class GoveeFanH1370 extends GoveeDevice {
355
385
  break
356
386
  }
357
387
  default:
388
+ if (this.handledStatusCodes.has(getTwoItemPosition(hexParts, 2))) {
389
+ break
390
+ }
358
391
  logUnknownData(this.accessory, {
359
392
  kind: 'status',
360
393
  source: params.source,
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
@@ -141,4 +141,5 @@ export default {
141
141
  syncStillRunning: 'skipping this sync as the previous one has not finished yet',
142
142
  viaAL: 'via adaptive lighting',
143
143
  statusCodes: 'device status codes',
144
+ cmdAcknowledged: 'command acknowledged',
144
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.13",
5
+ "version": "11.36.1-beta.15",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",