@homebridge-plugins/homebridge-govee 11.32.0-beta.26 → 11.32.0-beta.28

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
@@ -17,7 +17,7 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
17
17
  - chore: declare the supports-hap transport keyword for the homebridge ui
18
18
  - feat: add support for the H60B3 uplighter as an rgb light (#1325)
19
19
  - feat: add support for the H5901 water timer through the govee openapi (#1324)
20
- - fix(fans): map rotation speed as a percentage on every fan model, so homekit stops showing speeds like 7% and the top of the slider means full power (#1326)
20
+ - fix(fans): map rotation speed as a percentage on every fan model, so homekit stops showing speeds like 7% and the top of the slider means full power (#1326) (@cwhett)
21
21
  - chore: log what each device can do according to the govee openapi, in debug mode, to make working out new device support quicker
22
22
  - chore: decode the status codes a device reports into readable hex in debug mode, so support for new device features can be worked out from a normal log
23
23
  - feat: add the H5089 night light as a homekit light tile with on, off and brightness (#1323)
@@ -26,10 +26,15 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
26
26
  - fix: stop sending a per-outlet value to the govee api, which rejected every press with a parameter out of range error on two-outlet plugs (#1323)
27
27
  - fix: hide the H5089 night light tile until its commands are worked out, rather than showing a control that does nothing (#1323)
28
28
  - feat: add support for the H3001 solar string lights as an rgb light (#1328)
29
- - docs(changelog): list every unreleased commit in the pending section
30
29
  - fix(fans): rebuild the fan service once when it was cached under the old speed scale, so the top of the slider stops setting the slowest speed (#1326) (@cwhett)
31
30
  - fix: show the H5089 night light tile again, but refuse control commands, so it reports the real state instead of quietly ignoring taps (#1323)
32
31
  - fix(ice-makers): stop the home app showing an ice size of 0 when one has never been set (#1329) (@cwhett)
32
+ - chore: add a test setup with tests for the shared helper functions
33
+ - chore(github): run the build and tests in ci, on node 22, 24 and 26
34
+ - chore: use the same lint setup across every plugin
35
+ - chore: add a changelog:sync script to populate the pending section from the commits
36
+ - chore(deps): refresh the lock file for the eslint config bump
37
+ - fix(ble): size the update queue timeout from the connection stages, so a slow device still has time to send its command (#1328)
33
38
 
34
39
  ## v11.31.0 (2026-07-24)
35
40
 
@@ -3,7 +3,7 @@ import process from 'node:process'
3
3
 
4
4
  import btClient from '@stoprocent/noble'
5
5
 
6
- import { UUID } from '../utils/ble-protocol.js'
6
+ import { BLE_STAGE_TIMEOUTS, UUID } from '../utils/ble-protocol.js'
7
7
  import { decodeAny } from '../utils/decode.js'
8
8
  import { base64ToHex, generateCodeFromHexValues, hexToTwoItems, sleep } from '../utils/functions.js'
9
9
  import platformLang from '../utils/lang-en.js'
@@ -15,8 +15,11 @@ process.env.NOBLE_REPORT_ALL_HCI_EVENTS = '1' // needed on Linux including Raspb
15
15
 
16
16
  const H5075_UUID = 'ec88'
17
17
  const H5101_UUID = '0001'
18
- const CONNECTION_TIMEOUT = 10000 // 10 seconds
19
- const WRITE_TIMEOUT = 5000 // 5 seconds
18
+
19
+ const POWER_ON_TIMEOUT = BLE_STAGE_TIMEOUTS.powerOn
20
+ const CONNECTION_TIMEOUT = BLE_STAGE_TIMEOUTS.connect
21
+ const WRITE_TIMEOUT = BLE_STAGE_TIMEOUTS.write
22
+ const SETTLE_DELAY = BLE_STAGE_TIMEOUTS.settle
20
23
 
21
24
  /*
22
25
  The necessary commands to send and functions are taken from and credit to:
@@ -169,7 +172,7 @@ export default class BLEConnection {
169
172
  }
170
173
  }
171
174
 
172
- async waitForPowerOn(timeout = 5000) {
175
+ async waitForPowerOn(timeout = POWER_ON_TIMEOUT) {
173
176
  if (this.btState === 'poweredOn') {
174
177
  return true
175
178
  }
@@ -240,7 +243,7 @@ export default class BLEConnection {
240
243
  // Write without response then allow time for the BLE controller to transmit before disconnecting
241
244
  await this.writeWithTimeout(characteristic, finalBuffer, WRITE_TIMEOUT)
242
245
  accessory.logDebug('command sent successfully')
243
- await sleep(100)
246
+ await sleep(SETTLE_DELAY)
244
247
  } catch (err) {
245
248
  accessory.logWarn(`BLE update failed: ${err.message}`)
246
249
  throw err
package/lib/platform.js CHANGED
@@ -13,6 +13,7 @@ import lanClient from './connection/lan.js'
13
13
  import openApiClient from './connection/openapi.js'
14
14
  import deviceTypes from './device/index.js'
15
15
  import eveService from './fakegato/fakegato-history.js'
16
+ import { BLE_UPDATE_TIMEOUT } from './utils/ble-protocol.js'
16
17
  import { buildCommand } from './utils/command-builder.js'
17
18
  import platformConsts from './utils/constants.js'
18
19
  import platformChars from './utils/custom-chars.js'
@@ -595,7 +596,10 @@ export default class {
595
596
  concurrency: 1,
596
597
  interval: this.config.bleControlInterval * 1000,
597
598
  intervalCap: 1,
598
- timeout: 10000,
599
+ // Sized from the BLE stage timeouts so the two cannot drift apart. This
600
+ // is a backstop for a hung task, and must stay longer than the stages
601
+ // it wraps - see BLE_UPDATE_TIMEOUT.
602
+ timeout: BLE_UPDATE_TIMEOUT,
599
603
  throwOnTimeout: true,
600
604
  })
601
605
 
@@ -61,3 +61,35 @@ export const UUID = {
61
61
 
62
62
  // Total BLE packet size
63
63
  export const PACKET_SIZE = 20
64
+
65
+ // Timeouts for the individual stages of a single BLE updateDevice() call.
66
+ // They live here, rather than beside the code that uses them, so the platform
67
+ // can size its queue from them without importing the BLE client — that client
68
+ // pulls in a native module which is not present on every install.
69
+ export const BLE_STAGE_TIMEOUTS = {
70
+ powerOn: 5000,
71
+ connect: 10000,
72
+ write: 5000,
73
+ settle: 100, // let the controller transmit before disconnecting
74
+
75
+ // Discovering services, and the disconnect on the way out, have no timeout of
76
+ // their own, so leave them room in the total.
77
+ untimedStages: 3000,
78
+
79
+ // Headroom on top, so that a stage which times out reports its own error
80
+ // instead of being cut short by the queue's much vaguer one.
81
+ queueHeadroom: 2000,
82
+ }
83
+
84
+ /**
85
+ * The longest a single BLE updateDevice() call can legitimately take.
86
+ *
87
+ * The platform queue sizes its own timeout from this. It is meant as a backstop
88
+ * for a task that has hung, not a second deadline racing the stage timeouts.
89
+ * Both used to be a flat 10 seconds, which meant the queue killed a connection
90
+ * attempt at the exact moment that attempt gave up — and a light that connected
91
+ * slowly never got as far as sending its command at all (#1328).
92
+ */
93
+ export const BLE_UPDATE_TIMEOUT = Object
94
+ .values(BLE_STAGE_TIMEOUTS)
95
+ .reduce((total, stage) => total + stage, 0)
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { BLE_STAGE_TIMEOUTS, BLE_UPDATE_TIMEOUT } from './ble-protocol.js'
4
+
5
+ /**
6
+ * The bug these guard against (#1328): the BLE connect timeout and the platform
7
+ * queue timeout were both a flat 10000ms. The queue therefore killed a task at
8
+ * the exact moment the connect gave up, and any light that connected slowly had
9
+ * nothing left of the budget for discovering services and writing its command.
10
+ */
11
+ describe('bLE timeout budget', () => {
12
+ it('leaves room for every stage of an update', () => {
13
+ const stages = BLE_STAGE_TIMEOUTS.powerOn
14
+ + BLE_STAGE_TIMEOUTS.connect
15
+ + BLE_STAGE_TIMEOUTS.untimedStages
16
+ + BLE_STAGE_TIMEOUTS.write
17
+ + BLE_STAGE_TIMEOUTS.settle
18
+
19
+ expect(BLE_UPDATE_TIMEOUT).toBeGreaterThan(stages)
20
+ })
21
+
22
+ it('is not simply the connect timeout, which is what caused #1328', () => {
23
+ expect(BLE_UPDATE_TIMEOUT).not.toBe(BLE_STAGE_TIMEOUTS.connect)
24
+ })
25
+
26
+ it('still has time left after the slowest possible connect', () => {
27
+ // A connect that only just succeeds must leave enough behind to discover
28
+ // services and send the command - otherwise a slow device is
29
+ // indistinguishable from an unreachable one.
30
+ const afterSlowestConnect = BLE_UPDATE_TIMEOUT
31
+ - BLE_STAGE_TIMEOUTS.powerOn
32
+ - BLE_STAGE_TIMEOUTS.connect
33
+
34
+ expect(afterSlowestConnect).toBeGreaterThanOrEqual(
35
+ BLE_STAGE_TIMEOUTS.untimedStages + BLE_STAGE_TIMEOUTS.write + BLE_STAGE_TIMEOUTS.settle,
36
+ )
37
+ })
38
+
39
+ it('keeps every stage timeout positive', () => {
40
+ Object.entries(BLE_STAGE_TIMEOUTS).forEach(([stage, ms]) => {
41
+ expect(ms, stage).toBeGreaterThan(0)
42
+ })
43
+ })
44
+ })
@@ -0,0 +1,179 @@
1
+ import { Buffer } from 'node:buffer'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import {
6
+ base64ToHex,
7
+ cenToFar,
8
+ fanSpeedToHkPercent,
9
+ farToCen,
10
+ generateCodeFromHexValues,
11
+ getTwoItemPosition,
12
+ hasProperty,
13
+ hexToBase64,
14
+ hexToDecimal,
15
+ hexToTwoItems,
16
+ hkPercentToFanSpeed,
17
+ nearestHalf,
18
+ parseDeviceId,
19
+ parseError,
20
+ } from './functions.js'
21
+
22
+ /**
23
+ * The fan speed mapping is the one worth pinning hardest. Before #1310/#1326
24
+ * the plugin sent the raw HomeKit percentage as a speed, so a slider at 100%
25
+ * asked an 8-step fan for speed 100, and the Home app displayed speeds like
26
+ * "7%". These tests cover the round trip on both step counts we ship.
27
+ */
28
+ describe('fan speed mapping', () => {
29
+ it('maps the top of the slider to the fastest speed', () => {
30
+ expect(hkPercentToFanSpeed(100, 8)).toBe(8)
31
+ expect(hkPercentToFanSpeed(100, 12)).toBe(12)
32
+ })
33
+
34
+ it('never returns speed 0, which would mean "off" to the device', () => {
35
+ // HomeKit sends 0 when the fan is switched off. Speed 0 is not a valid
36
+ // speed, so the mapping clamps to the slowest instead.
37
+ expect(hkPercentToFanSpeed(0, 8)).toBe(1)
38
+ expect(hkPercentToFanSpeed(1, 8)).toBe(1)
39
+ })
40
+
41
+ it('never exceeds the number of steps the model has', () => {
42
+ expect(hkPercentToFanSpeed(150, 8)).toBe(8)
43
+ })
44
+
45
+ it('round trips every speed on an 8 step model', () => {
46
+ for (let speed = 1; speed <= 8; speed++) {
47
+ expect(hkPercentToFanSpeed(fanSpeedToHkPercent(speed, 8), 8)).toBe(speed)
48
+ }
49
+ })
50
+
51
+ it('round trips every speed on a 12 step model', () => {
52
+ for (let speed = 1; speed <= 12; speed++) {
53
+ expect(hkPercentToFanSpeed(fanSpeedToHkPercent(speed, 12), 12)).toBe(speed)
54
+ }
55
+ })
56
+
57
+ it('reports a stopped fan as 0 percent', () => {
58
+ expect(fanSpeedToHkPercent(0, 8)).toBe(0)
59
+ expect(fanSpeedToHkPercent(undefined, 8)).toBe(0)
60
+ })
61
+
62
+ it('shows auto mode at the top of the slider', () => {
63
+ // Chosen so the two paths disagree: without the auto case, speed 5 of 8
64
+ // would map to 63%. An auto speed that happens to sit above the step count
65
+ // would return 100 either way and would not prove the branch runs.
66
+ expect(fanSpeedToHkPercent(5, 8, 5)).toBe(100)
67
+ expect(fanSpeedToHkPercent(5, 8, undefined)).toBe(63)
68
+ })
69
+
70
+ it('does not treat auto as special when the model has no auto speed', () => {
71
+ expect(fanSpeedToHkPercent(4, 8, undefined)).toBe(50)
72
+ })
73
+ })
74
+
75
+ describe('parseDeviceId', () => {
76
+ it('uppercases and strips punctuation the api does not use', () => {
77
+ expect(parseDeviceId('ab:cd:ef')).toBe('AB:CD:EF')
78
+ expect(parseDeviceId('ab cd-ef')).toBe('ABCDEF')
79
+ })
80
+
81
+ it('keeps the separators govee device ids actually contain', () => {
82
+ // Colons and underscores are meaningful in a Govee device id, so the
83
+ // cleaner must not strip them along with the rest.
84
+ expect(parseDeviceId('AB:CD_EF')).toBe('AB:CD_EF')
85
+ })
86
+
87
+ it('accepts a non-string id without throwing', () => {
88
+ expect(parseDeviceId(123456)).toBe('123456')
89
+ })
90
+ })
91
+
92
+ describe('hex helpers', () => {
93
+ it('splits a hex string into byte pairs', () => {
94
+ expect(hexToTwoItems('aabbcc')).toEqual(['aa', 'bb', 'cc'])
95
+ })
96
+
97
+ it('reads a byte by its 1-based position, as the device handlers do', () => {
98
+ // Callers index from 1 because they are reading a protocol spec, not an
99
+ // array. Getting this off by one silently misreads every payload.
100
+ const parts = hexToTwoItems('aa1b0164')
101
+ expect(getTwoItemPosition(parts, 1)).toBe('aa')
102
+ expect(getTwoItemPosition(parts, 2)).toBe('1b')
103
+ expect(getTwoItemPosition(parts, 4)).toBe('64')
104
+ })
105
+
106
+ it('converts hex to decimal', () => {
107
+ expect(hexToDecimal('64')).toBe(100)
108
+ expect(hexToDecimal('ff')).toBe(255)
109
+ })
110
+
111
+ it('round trips base64 and hex', () => {
112
+ const hex = '330501000000000000000000000000000000000037'
113
+ expect(base64ToHex(hexToBase64(hex))).toBe(hex)
114
+ })
115
+ })
116
+
117
+ describe('generateCodeFromHexValues', () => {
118
+ it('builds a 20 byte packet', () => {
119
+ // Every Govee BLE/IoT command is exactly 20 bytes: 19 of payload plus a
120
+ // trailing XOR checksum.
121
+ const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x05', '0x01']), 'base64')
122
+ expect(packet).toHaveLength(20)
123
+ })
124
+
125
+ it('ends with an xor checksum over the first 19 bytes', () => {
126
+ const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x05', '0x01']), 'base64')
127
+ let expected = 0
128
+ for (let i = 0; i < 19; i++) {
129
+ expected ^= packet[i]
130
+ }
131
+ expect(packet[19]).toBe(expected)
132
+ })
133
+
134
+ it('pads short commands with zeroes rather than leaving them short', () => {
135
+ const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x01']), 'base64')
136
+ expect(packet.subarray(2, 19).every(b => b === 0)).toBe(true)
137
+ })
138
+ })
139
+
140
+ describe('temperature conversion', () => {
141
+ it('converts celsius to fahrenheit to one decimal place', () => {
142
+ expect(cenToFar(0)).toBe(32)
143
+ expect(cenToFar(100)).toBe(212)
144
+ expect(cenToFar(21.5)).toBe(70.7)
145
+ })
146
+
147
+ it('converts fahrenheit to whole celsius', () => {
148
+ expect(farToCen(32)).toBe(0)
149
+ expect(farToCen(212)).toBe(100)
150
+ })
151
+
152
+ it('rounds to the nearest half', () => {
153
+ expect(nearestHalf(21.24)).toBe(21)
154
+ expect(nearestHalf(21.26)).toBe(21.5)
155
+ expect(nearestHalf(21.75)).toBe(22)
156
+ })
157
+ })
158
+
159
+ describe('misc helpers', () => {
160
+ it('detects own properties only', () => {
161
+ expect(hasProperty({ a: 1 }, 'a')).toBe(true)
162
+ expect(hasProperty({}, 'toString')).toBe(false)
163
+ })
164
+
165
+ it('appends the first stack frame to an error message', () => {
166
+ const err = new Error('boom')
167
+ err.stack = 'Error: boom\n at thing (/a.js:1:1)'
168
+ expect(parseError(err)).toContain('boom')
169
+ expect(parseError(err)).toContain('at thing')
170
+ })
171
+
172
+ it('can be told to hide the stack for expected errors', () => {
173
+ // Some errors are routine (a device being offline) and their stack is
174
+ // noise in the log, so callers pass them in hideStack.
175
+ const err = new Error('offline')
176
+ err.stack = 'Error: offline\n at thing (/a.js:1:1)'
177
+ expect(parseError(err, ['offline'])).toBe('offline')
178
+ })
179
+ })
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.32.0-beta.26",
5
+ "version": "11.32.0-beta.28",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -49,12 +49,16 @@
49
49
  "main": "lib/index.js",
50
50
  "engines": {
51
51
  "homebridge": "^1.6.0 || ^2.0.0",
52
- "node": "^22.12.0 || ^24.0.0"
52
+ "node": "^22.12.0 || ^24.0.0 || ^26.0.0"
53
53
  },
54
54
  "scripts": {
55
55
  "lint": "eslint . --max-warnings=0",
56
56
  "lint:fix": "npm run lint -- --fix",
57
- "postinstall": "patch-package"
57
+ "postinstall": "patch-package",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest watch",
60
+ "test-coverage": "npm run test -- --coverage",
61
+ "changelog:sync": "node .github/scripts/sync-changelog.mjs"
58
62
  },
59
63
  "dependencies": {
60
64
  "@homebridge/plugin-ui-utils": "^2.2.5",
@@ -72,7 +76,9 @@
72
76
  "@stoprocent/noble": "^2.5.9"
73
77
  },
74
78
  "devDependencies": {
75
- "@antfu/eslint-config": "^9.2.0"
79
+ "@antfu/eslint-config": "^9.2.0",
80
+ "@vitest/coverage-v8": "^4.1.10",
81
+ "vitest": "^4.1.10"
76
82
  },
77
83
  "allowScripts": {
78
84
  "@stoprocent/bluetooth-hci-socket@2.2.6": true,