@homebridge-plugins/homebridge-govee 11.34.1 → 11.34.2-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.34.2 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - feat: control the H1250 zones with the device's own frame, as govee ignores its toggles (#1343) (@Principe1218)
10
+ - fix: explain in the log when zone tiles are missing because aws credentials are not set
11
+ - fix: restore debug logging when the plugin runs in a child bridge
12
+
5
13
  ## v11.34.1 (2026-08-06)
6
14
 
7
15
  ### Changed
@@ -6,14 +6,43 @@ import {
6
6
  } from '../utils/colour.js'
7
7
  import platformConsts from '../utils/constants.js'
8
8
  import {
9
+ base64ToHex,
9
10
  generateRandomString,
11
+ getTwoItemPosition,
10
12
  hasProperty,
13
+ hexToTwoItems,
11
14
  parseError,
12
15
  sleep,
13
16
  } from '../utils/functions.js'
14
17
  import platformLang from '../utils/lang-en.js'
15
18
  import GoveeDevice from './base.js'
16
19
 
20
+ /**
21
+ * The zone state of a light that reports both of its zones in one frame, as
22
+ * `aa 30 <main> <background>`.
23
+ *
24
+ * Reported under the same capability instances the OpenAPI uses, so zone state
25
+ * reads the same whichever connection carried it. This frame arrives unprompted
26
+ * whenever the zones change, including from the Govee app or the wall switch,
27
+ * which is how the tiles stay right rather than only echoing our own writes -
28
+ * all the toggles could ever manage on these models (#1333).
29
+ *
30
+ * @param {string[]} [commands] the base64 frames from the payload
31
+ * @returns {object} the zones found, keyed by capability instance
32
+ */
33
+ function readZoneFrame(commands = []) {
34
+ const zones = {}
35
+ commands.forEach((command) => {
36
+ const hexParts = hexToTwoItems(base64ToHex(command))
37
+ if (`${getTwoItemPosition(hexParts, 1)}${getTwoItemPosition(hexParts, 2)}` !== 'aa30') {
38
+ return
39
+ }
40
+ zones.mainLightToggle = getTwoItemPosition(hexParts, 3) === '01'
41
+ zones.backgroundLightToggle = getTwoItemPosition(hexParts, 4) === '01'
42
+ })
43
+ return zones
44
+ }
45
+
17
46
  export default class extends GoveeDevice {
18
47
  constructor(platform, accessory) {
19
48
  super(platform, accessory)
@@ -199,30 +228,51 @@ export default class extends GoveeDevice {
199
228
  // Some lights (e.g. the H1270 Ceiling Light Ultra) have two independently
200
229
  // controllable zones - a main/bottom panel and a background/top light. Govee
201
230
  // exposes these as OpenAPI toggle capabilities, so add a switch for each zone
202
- // the device reports. These are only controllable via the OpenAPI connection.
231
+ // the device reports, controlled over the OpenAPI connection.
232
+ //
233
+ // The models in `zoneLightModels` advertise those same capabilities but the
234
+ // cloud never delivers them, so they are driven by a raw frame over AWS
235
+ // instead. That is the only difference - the tiles, their state and the way
236
+ // a failure is undone are the same either way (#1333)
203
237
  this.zoneServices = {}
204
238
  this.zoneCache = {}
239
+ this.hasRawZones = platformConsts.zoneLightModels.includes(accessory.context.gvModel)
205
240
  const zoneToggleDefs = [
206
- { instance: 'mainLightToggle', name: 'Main Light' },
207
- { instance: 'backgroundLightToggle', name: 'Background Light' },
241
+ { instance: 'mainLightToggle', zone: 'main', name: 'Main Light' },
242
+ { instance: 'backgroundLightToggle', zone: 'background', name: 'Background Light' },
208
243
  ]
209
244
  const openApiCaps = accessory.context.openApiCapabilities || {}
210
- zoneToggleDefs.forEach(({ instance, name }) => {
245
+ zoneToggleDefs.forEach(({ instance, zone, name }) => {
211
246
  const existingService = this.accessory.getService(name)
212
- if (accessory.context.useOpenApiControl && openApiCaps[instance]) {
247
+
248
+ // The raw frame goes over AWS, which needs Govee account credentials
249
+ // rather than just an API key
250
+ const isControllable = this.hasRawZones
251
+ ? accessory.context.useAwsControl
252
+ : accessory.context.useOpenApiControl && openApiCaps[instance]
253
+
254
+ if (isControllable) {
213
255
  const service = existingService
214
256
  || this.accessory.addService(this.hapServ.Switch, name, instance)
215
257
  service
216
258
  .getCharacteristic(this.hapChar.On)
217
- .onSet(async value => this.internalZoneUpdate(instance, name, value))
259
+ .onSet(async value => this.internalZoneUpdate(instance, zone, name, value))
218
260
  this.zoneServices[instance] = service
219
261
  this.zoneCache[instance] = service.getCharacteristic(this.hapChar.On).value ? 1 : 0
220
262
  } else if (existingService) {
221
- // Capability no longer available (or OpenAPI disabled) so remove the switch
263
+ // Capability no longer available (or the connection it needs is off) so
264
+ // remove the switch
222
265
  this.accessory.removeService(existingService)
223
266
  }
224
267
  })
225
268
 
269
+ // Withholding the tiles without AWS is deliberate - tiles that swallow taps
270
+ // are worse than none - but silence would leave the owner with no idea the
271
+ // zones exist or what is missing, so say so once at startup
272
+ if (this.hasRawZones && !accessory.context.useAwsControl) {
273
+ this.accessory.log(platformLang.zoneNeedsAws)
274
+ }
275
+
226
276
  // Output the customised options to the log
227
277
  const useAwsControl = accessory.context.useAwsControl ? 'enabled' : 'disabled'
228
278
  const useBleControl = accessory.context.useBleControl ? 'enabled' : 'disabled'
@@ -274,7 +324,7 @@ export default class extends GoveeDevice {
274
324
  }
275
325
  }
276
326
 
277
- async internalZoneUpdate(instance, name, value) {
327
+ async internalZoneUpdate(instance, zone, name, value) {
278
328
  const service = this.zoneServices[instance]
279
329
  try {
280
330
  const newValue = value ? 1 : 0
@@ -284,15 +334,26 @@ export default class extends GoveeDevice {
284
334
  return
285
335
  }
286
336
 
287
- // These toggles are only controllable via the OpenAPI (cloud) connection
288
- await this.platform.sendDeviceUpdate(this.accessory, {
289
- cmd: 'openApi',
290
- openApi: {
291
- instance,
292
- capabilityType: 'devices.capabilities.toggle',
293
- value: newValue,
294
- },
295
- })
337
+ if (this.hasRawZones) {
338
+ // This model's toggles are accepted and ignored, so send the frame the
339
+ // device itself uses. The command builder leaves lanParams off, so this
340
+ // one skips LAN - which the device ignores - and goes over AWS
341
+ await this.platform.sendDeviceUpdate(this.accessory, {
342
+ cmd: 'zoneState',
343
+ zone,
344
+ value: newValue === 1,
345
+ })
346
+ } else {
347
+ // These toggles are only controllable via the OpenAPI (cloud) connection
348
+ await this.platform.sendDeviceUpdate(this.accessory, {
349
+ cmd: 'openApi',
350
+ openApi: {
351
+ instance,
352
+ capabilityType: 'devices.capabilities.toggle',
353
+ value: newValue,
354
+ },
355
+ })
356
+ }
296
357
 
297
358
  // Cache the new state and log
298
359
  this.zoneCache[instance] = newValue
@@ -663,11 +724,13 @@ export default class extends GoveeDevice {
663
724
  }
664
725
  }
665
726
 
666
- // Update any independently-controllable light zones (e.g. main/background)
667
- if (params.toggles) {
727
+ // Update any independently-controllable light zones (e.g. main/background),
728
+ // read from the device's own frame where the toggles report nothing
729
+ const zoneToggles = this.hasRawZones ? readZoneFrame(params.commands) : params.toggles
730
+ if (zoneToggles) {
668
731
  Object.entries(this.zoneServices).forEach(([instance, service]) => {
669
- if (hasProperty(params.toggles, instance)) {
670
- const newValue = params.toggles[instance] ? 1 : 0
732
+ if (hasProperty(zoneToggles, instance)) {
733
+ const newValue = zoneToggles[instance] ? 1 : 0
671
734
  if (this.zoneCache[instance] !== newValue) {
672
735
  this.zoneCache[instance] = newValue
673
736
  service.updateCharacteristic(this.hapChar.On, newValue === 1)
package/lib/platform.js CHANGED
@@ -55,7 +55,6 @@ export default class {
55
55
  try {
56
56
  this.api = api
57
57
  this.log = log
58
- this.isBeta = process.argv.includes('-D')
59
58
 
60
59
  // Configuration objects for accessories
61
60
  this.deviceConf = {}
@@ -111,14 +110,16 @@ export default class {
111
110
  // Log that the plugin initialisation has been successful
112
111
  this.log('%s.', platformLang.initialised)
113
112
 
114
- // Sort out some logging functions
115
- if (this.isBeta) {
116
- this.log.debug = this.log
117
- this.log.debugWarn = this.log.warn
118
- } else {
119
- this.log.debug = () => {}
120
- this.log.debugWarn = () => {}
121
- }
113
+ // Homebridge already decides whether debug lines are shown, and it gets it
114
+ // right in a child bridge too. This used to work it out for itself by
115
+ // looking for `-D` in the plugin's own process arguments, which a child
116
+ // bridge does not receive unless that bridge has its own debug setting
117
+ // turned on - so debug logging silently disappeared for anyone running the
118
+ // plugin in a child bridge with debug enabled globally.
119
+ //
120
+ // `debugWarn` has no equivalent on Homebridge's logger, so it goes out as
121
+ // a debug line too rather than being invented here.
122
+ this.log.debugWarn = (...args) => this.log.debug(...args)
122
123
 
123
124
  // Require any libraries that the plugin uses
124
125
  this.cusChar = new platformChars(this.api)
@@ -663,22 +664,21 @@ export default class {
663
664
  }
664
665
 
665
666
  applyAccessoryLogging(accessory) {
666
- if (this.isBeta) {
667
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
668
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
669
- accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
670
- accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
671
- } else {
672
- if (this.config.disableDeviceLogging) {
673
- accessory.log = () => {}
674
- accessory.logWarn = () => {}
675
- } else {
676
- accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
677
- accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
678
- }
679
- accessory.logDebug = () => {}
680
- accessory.logDebugWarn = () => {}
667
+ // Debug lines always go to Homebridge's debug logger, which prints them only
668
+ // when debug is on - so there is nothing to decide here. Working it out from
669
+ // the process arguments instead is what lost debug logging entirely for
670
+ // anyone running the plugin in a child bridge.
671
+ accessory.logDebug = msg => this.log.debug('[%s] %s.', accessory.displayName, msg)
672
+ accessory.logDebugWarn = accessory.logDebug
673
+
674
+ if (this.config.disableDeviceLogging) {
675
+ accessory.log = () => {}
676
+ accessory.logWarn = () => {}
677
+ return
681
678
  }
679
+
680
+ accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
681
+ accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
682
682
  }
683
683
 
684
684
  initialiseDevice(device) {
@@ -1,7 +1,7 @@
1
1
  import { CMD } from './ble-protocol.js'
2
2
  import { k2rgb } from './colour.js'
3
3
  import { getDeviceCapabilities } from './device-capabilities.js'
4
- import { base64ToHex } from './functions.js'
4
+ import { base64ToHex, generateCodeFromHexValues } from './functions.js'
5
5
 
6
6
  /**
7
7
  * Builds connection-specific command params from a high-level device command.
@@ -26,6 +26,8 @@ export function buildCommand(params, context) {
26
26
  return buildPtRealCommand(params)
27
27
  case 'openApi':
28
28
  return buildOpenApiCommand(params)
29
+ case 'zoneState':
30
+ return buildZoneStateCommand(params)
29
31
  case 'brightness':
30
32
  return buildBrightnessCommand(params, context)
31
33
  case 'color':
@@ -117,6 +119,32 @@ function buildOpenApiCommand(params) {
117
119
  }
118
120
  }
119
121
 
122
+ // A two-zone light reports its zones as `aa 30 <main> <background>` and takes
123
+ // `33 30 <zone> <state>` to set one of them, where zone 00 is the main panel
124
+ // and 01 the background ring (#1333)
125
+ const ZONE_BYTE = { main: 0x00, background: 0x01 }
126
+
127
+ function buildZoneStateCommand(params) {
128
+ const zone = ZONE_BYTE[params.zone]
129
+ if (zone === undefined) {
130
+ // Falling back to the main panel would switch the wrong light, which is far
131
+ // harder to notice than an outright failure
132
+ throw new Error(`Unknown light zone [${params.zone}] for ${params.cmd}`)
133
+ }
134
+
135
+ // Deliberately no lanParams. This model ignores `ptReal` over LAN, and LAN is
136
+ // fire-and-forget UDP, so sending it there would resolve and count as done -
137
+ // the command would never be retried over AWS. Leaving lanParams off lets the
138
+ // existing guard skip LAN for this one command, and every other command on
139
+ // the device carries on taking the faster local path.
140
+ return {
141
+ awsParams: {
142
+ cmd: 'ptReal',
143
+ data: { command: [generateCodeFromHexValues([0x33, 0x30, zone, params.value ? 0x01 : 0x00])] },
144
+ },
145
+ }
146
+ }
147
+
120
148
  function buildBrightnessCommand(params, context) {
121
149
  const caps = getDeviceCapabilities(context.gvModel, context.firmware)
122
150
  return {
@@ -714,6 +714,20 @@ export default {
714
714
  ],
715
715
  },
716
716
 
717
+ // Two-zone lights whose zones Govee lists but never routes. Not a device type
718
+ // of its own - these stay in `models.rgb` like any other light, and only the
719
+ // way their zones are driven differs.
720
+ //
721
+ // Govee advertises `mainLightToggle` and `backgroundLightToggle` for these,
722
+ // and its cloud answers `{"status":"success"}` to both, but the command never
723
+ // reaches the device. Nothing reports a failure, so the plugin cannot tell -
724
+ // the tiles simply do nothing. The models here take the raw frame the device
725
+ // itself reports its zones with instead (#1333).
726
+ //
727
+ // The H1270 is deliberately absent. It advertises the same capabilities, but
728
+ // nobody has confirmed it shares the fault, so it stays on the toggle path.
729
+ zoneLightModels: ['H1250'],
730
+
717
731
  matterModels: [
718
732
  'H1401',
719
733
  'H14C0',
@@ -113,6 +113,7 @@ export default {
113
113
  noExtFunc: 'no externalUpdate function to process incoming update',
114
114
  notAWSConn: 'not connected to AWS',
115
115
  notAWSSent: 'not using AWS connection as',
116
+ zoneNeedsAws: 'this light has separately switchable zones, but the zone tiles need your Govee account username and password in the plugin settings - an API key alone is not enough for them',
116
117
  notBLESent: 'not using BLE connection as',
117
118
  notLANSent: 'not using LAN connection as',
118
119
  noOpenssl: 'See https://github.com/homebridge-plugins/homebridge-govee/wiki/OpenSSL for more info about installing OpenSSL',
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.34.1",
5
+ "version": "11.34.2-beta.1",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",