@homebridge-plugins/homebridge-govee 11.24.0 → 11.25.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,6 +2,24 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-govee` will be documented in this file.
4
4
 
5
+ ## v11.25.0 (2026-07-12)
6
+
7
+ ### Changes
8
+
9
+ - fix: parse water leak detection from the openapi response (#1276) (#1312) (@dev-d)
10
+ - feat: add support for the Edison Bulb H14C0 (#1302) (@starrlord)
11
+ - feat: add support for the H1270 ceiling light with independent zone control (#1306) (@x86txt)
12
+ - fix: H7111 HomeKit rotation speed percentage mapping (#1310) (@u2jrmw)
13
+ - fix: clamp H7123 air quality to HomeKit range (#1261)
14
+ - fix: hide matter devices when ignoreMatter is enabled (#1287)
15
+ - feat: add H70C5 to the matter exclusion list (#1300)
16
+ - fix: clearer error when Govee login returns no token
17
+ - chore: update actions/checkout to v7
18
+ - chore: dependency updates
19
+ - fix: show temperature on the HomeKit tile for sensors
20
+ - feat: add support for new light models
21
+ - chore: allow install scripts for native BLE/USB dependencies
22
+
5
23
  ## v11.24.0 (2026-05-31)
6
24
 
7
25
  ### Changes
package/eslint.config.js CHANGED
@@ -35,7 +35,6 @@ export default antfu(
35
35
  'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
36
36
  'style/quote-props': ['error', 'consistent-as-needed'],
37
37
  'test/no-only-tests': 'error',
38
- 'unicorn/no-useless-spread': 'error',
39
38
  'unused-imports/no-unused-vars': ['error', { caughtErrors: 'none' }],
40
39
  },
41
40
  typescript: false,
@@ -59,7 +59,7 @@ export default class {
59
59
 
60
60
  // Check to see we got a response
61
61
  if (!res.data) {
62
- throw new Error(platformLang.noToken)
62
+ throw new Error(`${platformLang.noToken} (HTTP ${res.status})`)
63
63
  }
64
64
 
65
65
  // Handle 2FA requirement (status 454)
@@ -104,7 +104,7 @@ export default class {
104
104
  return await this.login()
105
105
  }
106
106
  }
107
- throw new Error(res.data.message || platformLang.noToken)
107
+ throw new Error(res.data.message || `${platformLang.noToken} (HTTP ${res.status})`)
108
108
  }
109
109
 
110
110
  // Make the token available in other functions
@@ -92,6 +92,8 @@ function getCapabilitiesFromPayload(data) {
92
92
  function getCapabilityValue(capability) {
93
93
  if (capability?.state && Object.hasOwn(capability.state, 'value')) {
94
94
  return capability.state.value
95
+ } else if (capability?.state && Array.isArray(capability.state)) {
96
+ return capability.state[0].value
95
97
  }
96
98
  return capability?.value
97
99
  }
@@ -560,6 +562,9 @@ export default class {
560
562
  message.capabilities.forEach((capability) => {
561
563
  const value = getCapabilityValue(capability)
562
564
  switch (capability?.instance) {
565
+ case 'bodyAppearedEvent':
566
+ normalized.leakDetected = !(Number(value) - 1)
567
+ break
563
568
  case 'online':
564
569
  normalized.online = !!value
565
570
  break
@@ -118,7 +118,7 @@ export default class {
118
118
  this.service
119
119
  .getCharacteristic(this.hapChar.RotationSpeed)
120
120
  .setProps({
121
- maxValue: this.autoSpeed,
121
+ maxValue: 100,
122
122
  minStep: 1,
123
123
  minValue: 0,
124
124
  unit: 'unitless',
@@ -176,6 +176,13 @@ export default class {
176
176
  return
177
177
  }
178
178
 
179
+ // HomeKit sends percentages
180
+ if (value >= 100) {
181
+ value = this.autoSpeed
182
+ } else if (value > 8) {
183
+ value = Math.max(1, Math.min(8, Math.round(value * 8 / 100)))
184
+ }
185
+
179
186
  // Don't continue if the new value is the same as before
180
187
  if (this.cacheSpeed === value) {
181
188
  return
@@ -258,10 +265,14 @@ export default class {
258
265
  } else {
259
266
  newSpeed = params.workMode.modeValue
260
267
  }
261
- if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed && this.cacheSpeed !== newSpeed) {
262
- this.cacheSpeed = newSpeed
263
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
264
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
268
+ if (Number.isFinite(newSpeed) && newSpeed >= 1 && newSpeed <= this.autoSpeed) {
269
+ const hkSpeed = newSpeed === this.autoSpeed ? 100 : Math.round((newSpeed / 8) * 100)
270
+
271
+ if (this.cacheSpeed !== hkSpeed) {
272
+ this.cacheSpeed = hkSpeed
273
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
274
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
275
+ }
265
276
  }
266
277
  }
267
278
 
@@ -276,7 +287,7 @@ export default class {
276
287
  }
277
288
 
278
289
  // Check for some other scene/mode change
279
- (params.commands || []).forEach((command) => {
290
+ ;(params.commands || []).forEach((command) => {
280
291
  const hexString = base64ToHex(command)
281
292
  const hexParts = hexToTwoItems(hexString)
282
293
 
@@ -298,19 +309,21 @@ export default class {
298
309
  case '0501': {
299
310
  // Fan speed
300
311
  const newSpeed = Number.parseInt(getTwoItemPosition(hexParts, 4), 16)
301
- if (newSpeed >= 1 && newSpeed <= 8 && this.cacheSpeed !== newSpeed) {
302
- this.cacheSpeed = newSpeed
303
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
304
- this.accessory.log(`${platformLang.curSpeed} [${this.cacheSpeed}]`)
312
+ if (newSpeed >= 1 && newSpeed <= 8) {
313
+ const hkSpeed = Math.round((newSpeed / 8) * 100)
314
+
315
+ this.cacheSpeed = hkSpeed
316
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, hkSpeed)
317
+ this.accessory.log(`${platformLang.curSpeed} [${newSpeed} -> ${hkSpeed}%]`)
305
318
  }
306
319
  break
307
320
  }
308
321
  case '0500': {
309
322
  // Mode change (auto = 0x03)
310
323
  if (getTwoItemPosition(hexParts, 4) === '03' && this.cacheSpeed !== this.autoSpeed) {
311
- this.cacheSpeed = this.autoSpeed
312
- this.service.updateCharacteristic(this.hapChar.RotationSpeed, this.cacheSpeed)
313
- this.accessory.log(`${platformLang.curSpeed} [auto]`)
324
+ this.cacheSpeed = 100
325
+ this.service.updateCharacteristic(this.hapChar.RotationSpeed, 100)
326
+ this.accessory.log(`${platformLang.curSpeed} [auto -> 100%]`)
314
327
  }
315
328
  break
316
329
  }
@@ -198,6 +198,33 @@ export default class {
198
198
  this.accessory.context.adaptiveLighting = true
199
199
  }
200
200
 
201
+ // Some lights (e.g. the H1270 Ceiling Light Ultra) have two independently
202
+ // controllable zones - a main/bottom panel and a background/top light. Govee
203
+ // exposes these as OpenAPI toggle capabilities, so add a switch for each zone
204
+ // the device reports. These are only controllable via the OpenAPI connection.
205
+ this.zoneServices = {}
206
+ this.zoneCache = {}
207
+ const zoneToggleDefs = [
208
+ { instance: 'mainLightToggle', name: 'Main Light' },
209
+ { instance: 'backgroundLightToggle', name: 'Background Light' },
210
+ ]
211
+ const openApiCaps = accessory.context.openApiCapabilities || {}
212
+ zoneToggleDefs.forEach(({ instance, name }) => {
213
+ const existingService = this.accessory.getService(name)
214
+ if (accessory.context.useOpenApiControl && openApiCaps[instance]) {
215
+ const service = existingService
216
+ || this.accessory.addService(this.hapServ.Switch, name, instance)
217
+ service
218
+ .getCharacteristic(this.hapChar.On)
219
+ .onSet(async value => this.internalZoneUpdate(instance, name, value))
220
+ this.zoneServices[instance] = service
221
+ this.zoneCache[instance] = service.getCharacteristic(this.hapChar.On).value ? 1 : 0
222
+ } else if (existingService) {
223
+ // Capability no longer available (or OpenAPI disabled) so remove the switch
224
+ this.accessory.removeService(existingService)
225
+ }
226
+ })
227
+
201
228
  // Output the customised options to the log
202
229
  const useAwsControl = accessory.context.useAwsControl ? 'enabled' : 'disabled'
203
230
  const useBleControl = accessory.context.useBleControl ? 'enabled' : 'disabled'
@@ -249,6 +276,43 @@ export default class {
249
276
  }
250
277
  }
251
278
 
279
+ async internalZoneUpdate(instance, name, value) {
280
+ const service = this.zoneServices[instance]
281
+ try {
282
+ const newValue = value ? 1 : 0
283
+
284
+ // Don't continue if the new value is the same as before
285
+ if (this.zoneCache[instance] === newValue) {
286
+ return
287
+ }
288
+
289
+ // These toggles are only controllable via the OpenAPI (cloud) connection
290
+ await this.platform.sendDeviceUpdate(this.accessory, {
291
+ cmd: 'openApi',
292
+ openApi: {
293
+ instance,
294
+ capabilityType: 'devices.capabilities.toggle',
295
+ value: newValue,
296
+ },
297
+ })
298
+
299
+ // Cache the new state and log
300
+ this.zoneCache[instance] = newValue
301
+ this.accessory.log(`[${name}] ${platformLang.curState} [${value ? 'on' : 'off'}]`)
302
+ } catch (err) {
303
+ // Catch any errors during the process
304
+ this.accessory.logWarn(`[${name}] ${platformLang.devNotUpdated} ${parseError(err)}`)
305
+
306
+ // Throw a 'no response' error and revert this after 2 seconds
307
+ setTimeout(() => {
308
+ if (service) {
309
+ service.updateCharacteristic(this.hapChar.On, this.zoneCache[instance] === 1)
310
+ }
311
+ }, 2000)
312
+ throw new this.hapErr(-70402)
313
+ }
314
+ }
315
+
252
316
  async internalBrightnessUpdate(value) {
253
317
  try {
254
318
  // This acts like a debounce function when endlessly sliding the brightness scale
@@ -613,5 +677,19 @@ export default class {
613
677
  }
614
678
  }
615
679
  }
680
+
681
+ // Update any independently-controllable light zones (e.g. main/background)
682
+ if (params.toggles) {
683
+ Object.entries(this.zoneServices).forEach(([instance, service]) => {
684
+ if (hasProperty(params.toggles, instance)) {
685
+ const newValue = params.toggles[instance] ? 1 : 0
686
+ if (this.zoneCache[instance] !== newValue) {
687
+ this.zoneCache[instance] = newValue
688
+ service.updateCharacteristic(this.hapChar.On, newValue === 1)
689
+ this.accessory.log(`[${service.displayName}] ${platformLang.curState} [${newValue === 1 ? 'on' : 'off'}]`)
690
+ }
691
+ }
692
+ })
693
+ }
616
694
  }
617
695
  }
@@ -74,10 +74,11 @@ export default class {
74
74
  }
75
75
 
76
76
  this.airValue2Label = {
77
+ 0: 'unknown',
77
78
  1: 'excellent',
78
79
  2: 'good',
79
80
  3: 'moderate',
80
- 4: 'poor',
81
+ 5: 'poor',
81
82
  }
82
83
 
83
84
  // Add the purifier service if it doesn't already exist
@@ -469,13 +470,19 @@ export default class {
469
470
  }
470
471
  case 'aa19': {
471
472
  // Check air quality reading (i.e. 1=green, 2=blue, 3=yellow, 4=red)
472
- // Cache will be in {1, 2, 3, 5} which relates to Govee {1, 2, 3, 4}
473
+ // Govee reports 1-4 (excellent→poor); HomeKit AirQuality accepts 0-5.
474
+ // Map Govee's 'poor' (and any higher/unexpected reading) to HomeKit's
475
+ // Poor (5), and treat anything invalid as Unknown (0), so HomeKit is
476
+ // never sent an out-of-range value (#1261).
473
477
  let newQual = Number.parseInt(getTwoItemPosition(hexParts, 5), 16)
474
- if (newQual === 4) {
478
+ if (!Number.isInteger(newQual) || newQual < 1) {
479
+ newQual = 0
480
+ } else if (newQual >= 4) {
475
481
  newQual = 5
476
482
  }
477
483
 
478
484
  if (newQual !== this.cacheAir) {
485
+ this.cacheAir = newQual
479
486
  this.airService.updateCharacteristic(this.hapChar.AirQuality, newQual)
480
487
  this.accessory.log(`${platformLang.curAirQual} [${this.airValue2Label[newQual]}]`)
481
488
  }
@@ -29,6 +29,8 @@ export default class {
29
29
  // Add the temperature service if it doesn't already exist
30
30
  this.tempService = this.accessory.getService(this.hapServ.TemperatureSensor)
31
31
  || this.accessory.addService(this.hapServ.TemperatureSensor)
32
+ // Mark it as the primary service so the HomeKit tile shows the temperature
33
+ this.tempService.setPrimaryService(true)
32
34
  this.cacheTemp = this.tempService.getCharacteristic(this.hapChar.CurrentTemperature).value
33
35
  this.updateCache()
34
36
 
@@ -33,6 +33,8 @@ export default class {
33
33
  // Add the temperature service if it doesn't already exist
34
34
  this.tempService = this.accessory.getService(this.hapServ.TemperatureSensor)
35
35
  || this.accessory.addService(this.hapServ.TemperatureSensor)
36
+ // Mark it as the primary service so the HomeKit tile shows the temperature
37
+ this.tempService.setPrimaryService(true)
36
38
  this.cacheTemp = this.tempService.getCharacteristic(this.hapChar.CurrentTemperature).value
37
39
 
38
40
  // Add the battery service if it doesn't already exist
package/lib/platform.js CHANGED
@@ -718,12 +718,16 @@ export default class {
718
718
 
719
719
  // Check for redundant Homebridge accessories
720
720
  devicesInHB.forEach((accessory) => {
721
- // If the accessory doesn't exist in Govee then remove it
721
+ // If the accessory doesn't exist in Govee then remove it. Also remove it
722
+ // if it is now being ignored, or if it is a matter model and the user has
723
+ // enabled ignoreMatter (otherwise the cached accessory lingers even though
724
+ // the device is skipped during setup) (#1287).
722
725
  if (
723
726
  (!httpDevices.some(el => el.device === accessory.context.gvDeviceId)
724
727
  && !lanDevices.some(el => el.device === accessory.context.gvDeviceId)
725
728
  && !openApiDevices.some(el => el.device === accessory.context.gvDeviceId))
726
729
  || this.ignoredDevices.includes(accessory.context.gvDeviceId)
730
+ || (this.config.ignoreMatter && platformConsts.matterModels.includes(accessory.context.gvModel))
727
731
  ) {
728
732
  this.removeAccessory(accessory)
729
733
  }
@@ -128,7 +128,9 @@ export default {
128
128
 
129
129
  models: {
130
130
  rgb: [
131
+ 'H1270', // Ceiling Light Ultra
131
132
  'H1401',
133
+ 'H14C0', // https://github.com/homebridge-plugins/homebridge-govee/issues/1301
132
134
  'H1630',
133
135
  'H16B0', // https://github.com/homebridge-plugins/homebridge-govee/issues/1278
134
136
  'H16C0', // https://github.com/homebridge-plugins/homebridge-govee/issues/1286
@@ -391,6 +393,7 @@ export default {
391
393
  'H7024',
392
394
  'H7025',
393
395
  'H7026',
396
+ 'H7027', // https://github.com/homebridge-plugins/homebridge-govee/issues/1305
394
397
  'H7028',
395
398
  'H7029',
396
399
  'H702A',
@@ -406,6 +409,7 @@ export default {
406
409
  'H703B',
407
410
  'H7041',
408
411
  'H7042',
412
+ 'H7046', // https://github.com/homebridge-plugins/homebridge-govee/issues/1297
409
413
  'H7050',
410
414
  'H7051',
411
415
  'H7052',
@@ -469,6 +473,7 @@ export default {
469
473
  'H801C',
470
474
  'H8022',
471
475
  'H8025',
476
+ 'H802A', // https://github.com/homebridge-plugins/homebridge-govee/issues/1295
472
477
  'H805A',
473
478
  'H805B',
474
479
  'H805C',
@@ -558,6 +563,7 @@ export default {
558
563
 
559
564
  matterModels: [
560
565
  'H1401',
566
+ 'H14C0',
561
567
  'H3200',
562
568
  'H5085',
563
569
  'H600B',
@@ -590,6 +596,7 @@ export default {
590
596
  'H7075',
591
597
  'H7094',
592
598
  'H70C4',
599
+ 'H70C5',
593
600
  'H8015',
594
601
  ],
595
602
 
@@ -113,7 +113,7 @@ export default {
113
113
  notBLESent: 'not using BLE connection as',
114
114
  notLANSent: 'not using LAN connection as',
115
115
  noOpenssl: 'See https://github.com/homebridge-plugins/homebridge-govee/wiki/OpenSSL for more info about installing OpenSSL',
116
- noToken: 'no data received from Govee server whilst obtaining token',
116
+ noToken: 'could not obtain an account token from Govee - the account may need attention in the Govee Home app (e.g. an email verification code or accepting updated terms), or it may be served by a region this plugin does not support. If you only use Bluetooth or LAN control then this cloud login is not required',
117
117
  twoFARequired: '2FA verification required - a code has been sent to your email, please add it to the "code" field in your plugin config and restart Homebridge',
118
118
  twoFACodeInvalid: '2FA verification code was provided but login still failed - please check the code is correct and has not expired',
119
119
  noTokenExists: 'no account token has been retrieved',
@@ -77,6 +77,11 @@ export function parseDeviceUpdate(params, context) {
77
77
  // LEAK DETECTED
78
78
  if (hasProperty(params, 'leakDetected')) {
79
79
  data.leakDetected = params.leakDetected
80
+ } else if (params?.state?.leakDetected) {
81
+ data.leakDetected = params.state.leakDetected
82
+ }
83
+ if (params?.state && hasProperty(params.state, 'online')) {
84
+ data.online = params.state.online
80
85
  }
81
86
 
82
87
  // CURRENT TEMPERATURE
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.24.0",
5
+ "version": "11.25.0",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -56,21 +56,27 @@
56
56
  "postinstall": "patch-package"
57
57
  },
58
58
  "dependencies": {
59
- "@homebridge/plugin-ui-utils": "^2.2.4",
59
+ "@homebridge/plugin-ui-utils": "^2.2.5",
60
60
  "aws-iot-device-sdk": "^2.2.16",
61
- "axios": "^1.16.1",
62
- "mqtt": "^5.12.1",
61
+ "axios": "^1.18.1",
62
+ "mqtt": "^5.15.2",
63
63
  "node-persist": "^4.0.4",
64
64
  "node-rsa": "^2.0.0",
65
- "p-queue": "^9.3.0",
65
+ "p-queue": "^9.3.1",
66
66
  "patch-package": "^8.0.1",
67
67
  "pem": "^1.14.8"
68
68
  },
69
69
  "optionalDependencies": {
70
70
  "@stoprocent/bluetooth-hci-socket": "^2.2.6",
71
- "@stoprocent/noble": "^2.5.3"
71
+ "@stoprocent/noble": "^2.5.5"
72
72
  },
73
73
  "devDependencies": {
74
- "@antfu/eslint-config": "^9.0.0"
74
+ "@antfu/eslint-config": "^9.1.0"
75
+ },
76
+ "allowScripts": {
77
+ "@stoprocent/bluetooth-hci-socket@2.2.6": true,
78
+ "@stoprocent/noble@2.5.5": true,
79
+ "usb@2.18.0": true,
80
+ "@serialport/bindings-cpp@12.0.1": true
75
81
  }
76
82
  }