@homebridge-plugins/homebridge-govee 11.32.2-beta.7 → 11.32.2-beta.8

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
@@ -20,6 +20,7 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
20
20
  - feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
21
21
  - feat: give every appliance type a starting handler, so a new model works for on and off and asks for the rest
22
22
  - refactor: point models that behave identically at one shared handler instead of copies of the same file
23
+ - fix: stop shipping the test files to npm, since they are only needed in the repository
23
24
 
24
25
  ## v11.32.1 (2026-08-02)
25
26
 
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.2-beta.7",
5
+ "version": "11.32.2-beta.8",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -1,150 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- import { resetReportedUnknowns } from '../utils/report-unknown.js'
4
- import { deviceHumidifierBasic, devicePurifierBasic } from './appliance-basic.js'
5
-
6
- /**
7
- * Minimal stand-ins for the HomeKit objects. Enough to see which service a
8
- * device is given and which controls it ends up with.
9
- */
10
- function makeCharacteristic(value) {
11
- return {
12
- value,
13
- onSet(fn) {
14
- this.setHandler = fn
15
- return this
16
- },
17
- setProps() {
18
- return this
19
- },
20
- }
21
- }
22
-
23
- function makeService(name) {
24
- const chars = new Map()
25
- return {
26
- name,
27
- getCharacteristic(char) {
28
- if (!chars.has(char)) {
29
- chars.set(char, makeCharacteristic(char === 'On' ? false : 0))
30
- }
31
- return chars.get(char)
32
- },
33
- updateCharacteristic(char, value) {
34
- this.getCharacteristic(char).value = value
35
- return this
36
- },
37
- testCharacteristic: char => chars.has(char),
38
- removeCharacteristic() {},
39
- chars,
40
- }
41
- }
42
-
43
- function makePlatform() {
44
- // Service and characteristic names stand in for themselves, so a test can
45
- // assert on which one was picked
46
- const proxy = new Proxy({}, { get: (_t, prop) => prop })
47
- return {
48
- log: Object.assign(vi.fn(), { warn: vi.fn(), debug: vi.fn() }),
49
- api: { hap: { Service: proxy, Characteristic: proxy, HapStatusError: class {} } },
50
- }
51
- }
52
-
53
- function makeAccessory(capabilities) {
54
- const services = new Map()
55
- return {
56
- displayName: 'Test Appliance',
57
- context: {
58
- gvModel: 'H7146',
59
- gvDeviceId: 'AA:BB',
60
- openApiCapabilities: capabilities,
61
- },
62
- getService: name => services.get(name),
63
- addService(name) {
64
- const svc = makeService(name)
65
- services.set(name, svc)
66
- return svc
67
- },
68
- removeService(svc) {
69
- services.delete(svc.name)
70
- },
71
- log: vi.fn(),
72
- logWarn: vi.fn(),
73
- logDebug: vi.fn(),
74
- services,
75
- }
76
- }
77
-
78
- describe('basic appliance handler', () => {
79
- beforeEach(() => {
80
- resetReportedUnknowns()
81
- })
82
-
83
- describe('choosing the tile for a humidifier', () => {
84
- it('uses the plain fan when the device reports no humidity', () => {
85
- // HomeKit's humidifier must show a current humidity, and many govee
86
- // humidifiers never send one - so the safe tile is the plain fan
87
- const accessory = makeAccessory({})
88
- const device = new deviceHumidifierBasic(makePlatform(), accessory)
89
-
90
- expect(device.service.name).toBe('Fan')
91
- expect(accessory.services.has('HumidifierDehumidifier')).toBe(false)
92
- })
93
-
94
- it('uses the proper humidifier when the device does report humidity', () => {
95
- const accessory = makeAccessory({ sensorHumidity: { type: 'sensor' } })
96
- const device = new deviceHumidifierBasic(makePlatform(), accessory)
97
-
98
- expect(device.service.name).toBe('HumidifierDehumidifier')
99
- })
100
-
101
- it('switches the power characteristic to suit the tile', () => {
102
- // the plain fan switches on a boolean, the humidifier on active
103
- const plain = new deviceHumidifierBasic(makePlatform(), makeAccessory({}))
104
- const rich = new deviceHumidifierBasic(
105
- makePlatform(),
106
- makeAccessory({ sensorHumidity: {} }),
107
- )
108
-
109
- expect(plain.powerChar).toBe('On')
110
- expect(plain.powerOn).toBe(true)
111
- expect(rich.powerChar).toBe('Active')
112
- expect(rich.powerOn).toBe(1)
113
- })
114
-
115
- it('passes a humidity reading through only on the richer tile', () => {
116
- const rich = new deviceHumidifierBasic(
117
- makePlatform(),
118
- makeAccessory({ sensorHumidity: {} }),
119
- )
120
- rich.externalUpdate({ humidity: 55 })
121
-
122
- expect(rich.service.getCharacteristic('CurrentRelativeHumidity').value).toBe(55)
123
- })
124
- })
125
-
126
- it('keeps the current-state characteristic on a purifier', () => {
127
- // the purifier has no fallback tile, so its state characteristic must stay
128
- const device = new devicePurifierBasic(makePlatform(), makeAccessory({}))
129
-
130
- expect(device.service.name).toBe('AirPurifier')
131
- expect(device.currentStateChar).toBe('CurrentAirPurifierState')
132
- })
133
-
134
- it('reports an unknown control and fails the request', async () => {
135
- const accessory = makeAccessory({})
136
- const device = new deviceHumidifierBasic(makePlatform(), accessory)
137
-
138
- await expect(device.internalVariableUpdate(50)).rejects.toBeDefined()
139
- expect(accessory.logWarn).toHaveBeenCalledTimes(1)
140
- expect(accessory.logWarn.mock.calls[0][0]).toContain('mist level')
141
- })
142
-
143
- it('ignores the control following the device being switched off', async () => {
144
- const accessory = makeAccessory({})
145
- const device = new deviceHumidifierBasic(makePlatform(), accessory)
146
-
147
- await expect(device.internalVariableUpdate(0)).resolves.toBeUndefined()
148
- expect(accessory.logWarn).not.toHaveBeenCalled()
149
- })
150
- })
@@ -1,31 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import { getHeaterFeatures } from './appliance-capabilities.js'
4
-
5
- describe('appliance capabilities', () => {
6
- describe('heaters', () => {
7
- it('offers the full set to a model that is not described', () => {
8
- // Anything not listed keeps what the shared handler has always offered,
9
- // so adding an entry is opt-in and no existing model changes by accident
10
- expect(getHeaterFeatures('H7130')).toEqual({ swing: true, lock: true })
11
- expect(getHeaterFeatures('H713A')).toEqual({ swing: true, lock: true })
12
- })
13
-
14
- it('withholds controls the newer lite heaters do not have', () => {
15
- expect(getHeaterFeatures('H7137')).toEqual({ swing: false, lock: false })
16
- expect(getHeaterFeatures('H713E')).toEqual({ swing: false, lock: false })
17
- })
18
-
19
- it('copes with an unknown or missing model', () => {
20
- expect(getHeaterFeatures('NOPE')).toEqual({ swing: true, lock: true })
21
- expect(getHeaterFeatures(undefined)).toEqual({ swing: true, lock: true })
22
- })
23
-
24
- it('does not let a caller change the stored defaults', () => {
25
- const features = getHeaterFeatures('H7130')
26
- features.swing = false
27
-
28
- expect(getHeaterFeatures('H7130').swing).toBe(true)
29
- })
30
- })
31
- })
@@ -1,44 +0,0 @@
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
- })
@@ -1,105 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import { decodeH5074Values, decodeH5075Values, decodeH5179Values } from './decode.js'
4
-
5
- /**
6
- * Helpers that build a advertisement exactly as the sensor would, so a decoder
7
- * is checked against a known reading rather than against itself.
8
- */
9
- function leHex(value) {
10
- const v = value & 0xFFFF
11
- return `${(v & 0xFF).toString(16).padStart(2, '0')}${((v >> 8) & 0xFF).toString(16).padStart(2, '0')}`
12
- }
13
-
14
- function byteHex(value) {
15
- return value.toString(16).padStart(2, '0')
16
- }
17
-
18
- // H5074: temperature and humidity are little-endian pairs at bytes 3-4 and 5-6,
19
- // with the battery at byte 7
20
- function buildH5074(tempInC, humidity, battery) {
21
- return `88ec00${leHex(Math.round(tempInC * 100))}${leHex(Math.round(humidity * 100))}${byteHex(battery)}00`
22
- }
23
-
24
- // H5179: same shape but shifted along, temperature at bytes 6-7, humidity at
25
- // bytes 8-9 and the battery at byte 10
26
- function buildH5179(tempInC, humidity, battery) {
27
- return `0188ec000102${leHex(Math.round(tempInC * 100))}${leHex(Math.round(humidity * 100))}${byteHex(battery)}`
28
- }
29
-
30
- describe('bLE sensor decoding', () => {
31
- describe('h5074', () => {
32
- it('reads back a known reading', () => {
33
- const result = decodeH5074Values(buildH5074(22.5, 45, 88))
34
-
35
- expect(result.tempInC).toBe(22.5)
36
- expect(result.humidity).toBe(45)
37
- expect(result.battery).toBe(88)
38
- })
39
-
40
- it('handles a temperature below freezing', () => {
41
- expect(decodeH5074Values(buildH5074(-5.25, 30, 50)).tempInC).toBe(-5.25)
42
- })
43
- })
44
-
45
- describe('h5179', () => {
46
- /**
47
- * The bug this guards against: the temperature was built from the byte that
48
- * actually holds the low half of the humidity, so a real 22.5C was reported
49
- * as 21.96C. Wrong, but close enough to look believable, which is why it
50
- * went unnoticed - hence a test that pins an exact expected value.
51
- */
52
- it('reads back a known reading', () => {
53
- const result = decodeH5179Values(buildH5179(22.5, 45, 100))
54
-
55
- expect(result.tempInC).toBe(22.5)
56
- expect(result.humidity).toBe(45)
57
- expect(result.battery).toBe(100)
58
- })
59
-
60
- it('handles a temperature below freezing', () => {
61
- const result = decodeH5179Values(buildH5179(-3.5, 55.25, 20))
62
-
63
- expect(result.tempInC).toBe(-3.5)
64
- expect(result.humidity).toBe(55.25)
65
- })
66
-
67
- it('does not let the humidity change the temperature', () => {
68
- // same temperature, different humidity - the temperature must not move
69
- const a = decodeH5179Values(buildH5179(20, 30, 90))
70
- const b = decodeH5179Values(buildH5179(20, 70, 90))
71
-
72
- expect(a.tempInC).toBe(20)
73
- expect(b.tempInC).toBe(20)
74
- })
75
-
76
- it('converts to fahrenheit from the corrected celsius', () => {
77
- expect(decodeH5179Values(buildH5179(100, 50, 80)).tempInF).toBe(212)
78
- })
79
- })
80
-
81
- describe('h5075', () => {
82
- it('reads the temperature and humidity out of the shared packed value', () => {
83
- // This model packs both readings into one 3-byte number, temperature
84
- // scaled by 10000 with the humidity in the last three digits, so the two
85
- // cannot be set independently the way they can on the models above.
86
- const packed = 225450
87
- const result = decodeH5075Values(`88ec00${packed.toString(16).padStart(6, '0')}58`)
88
-
89
- expect(result.tempInC).toBe(22.545)
90
- expect(result.humidity).toBe(45)
91
- expect(result.battery).toBe(88)
92
- })
93
-
94
- it('reads a temperature below freezing from the sign bit', () => {
95
- const packed = 0x800000 | 55000
96
- const result = decodeH5075Values(`88ec00${packed.toString(16).padStart(6, '0')}58`)
97
-
98
- expect(result.tempInC).toBe(-5.5)
99
- })
100
- })
101
-
102
- it('rejects a stream that is too short to hold a reading', () => {
103
- expect(() => decodeH5179Values('0188ec00')).toThrow()
104
- })
105
- })
@@ -1,62 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
-
3
- import { COLOR_SUB } from './ble-protocol.js'
4
- import { getDeviceCapabilities } from './device-capabilities.js'
5
-
6
- /**
7
- * The bug these guard against (#1332): the H6102 was sent the 0x0D colour
8
- * command, which it accepts and then ignores, so on, off and brightness worked
9
- * while colour silently did nothing. Which colour command the strip wants
10
- * depends on its firmware, so the capability lookup has to take the version
11
- * into account rather than picking one for every H6102.
12
- */
13
- describe('device capabilities', () => {
14
- it('gives an unlisted model the defaults', () => {
15
- const caps = getDeviceCapabilities('H0000')
16
-
17
- expect(caps.bleColorCmd).toEqual([COLOR_SUB.RGB_DEFAULT])
18
- expect(caps.bleColorCmdSuffix).toEqual([])
19
- expect(caps.bleBrightnessScale).toBe(0xFF)
20
- })
21
-
22
- describe('h6102 colour command by firmware', () => {
23
- it('uses the extended command from 1.03.01 upwards', () => {
24
- const caps = getDeviceCapabilities('H6102', '1.03.01')
25
-
26
- expect(caps.bleColorCmd).toEqual(COLOR_SUB.RGB_EXTENDED)
27
- expect(caps.bleColorCmdSuffix).toEqual([0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x7F])
28
- })
29
-
30
- it('uses the extended command on later firmware too', () => {
31
- expect(getDeviceCapabilities('H6102', '1.06.00').bleColorCmd).toEqual(COLOR_SUB.RGB_EXTENDED)
32
- })
33
-
34
- it('keeps the standard command below 1.03.01', () => {
35
- const caps = getDeviceCapabilities('H6102', '1.02.99')
36
-
37
- expect(caps.bleColorCmd).toEqual([COLOR_SUB.RGB_DEFAULT])
38
- expect(caps.bleColorCmdSuffix).toEqual([])
39
- })
40
-
41
- it('keeps the standard command when the firmware is unknown', () => {
42
- expect(getDeviceCapabilities('H6102').bleColorCmd).toEqual([COLOR_SUB.RGB_DEFAULT])
43
- })
44
-
45
- it('keeps the brightness scale whatever the firmware', () => {
46
- expect(getDeviceCapabilities('H6102').bleBrightnessScale).toBe(0x64)
47
- expect(getDeviceCapabilities('H6102', '1.06.00').bleBrightnessScale).toBe(0x64)
48
- })
49
- })
50
-
51
- it('compares version parts as numbers, not text', () => {
52
- // "1.10.00" is newer than "1.03.01" even though it sorts earlier as text
53
- expect(getDeviceCapabilities('H6102', '1.10.00').bleColorCmd).toEqual(COLOR_SUB.RGB_EXTENDED)
54
- // and a shorter string is padded rather than treated as newer
55
- expect(getDeviceCapabilities('H6102', '1.3').bleColorCmd).toEqual([COLOR_SUB.RGB_DEFAULT])
56
- })
57
-
58
- it('leaves a firmware-gated model alone when another model matches', () => {
59
- // the H617A is not firmware gated, so its override applies at any version
60
- expect(getDeviceCapabilities('H617A', '1.00.00').bleColorCmd).toEqual(COLOR_SUB.RGB_EXTENDED)
61
- })
62
- })
@@ -1,292 +0,0 @@
1
- import { Buffer } from 'node:buffer'
2
-
3
- import { describe, expect, it } from 'vitest'
4
-
5
- import {
6
- base64ToHex,
7
- cenToFar,
8
- describeControlFailure,
9
- fanSpeedToHkPercent,
10
- farToCen,
11
- generateCodeFromHexValues,
12
- getTwoItemPosition,
13
- hasProperty,
14
- hexToBase64,
15
- hexToDecimal,
16
- hexToTwoItems,
17
- hkPercentToFanSpeed,
18
- nearestHalf,
19
- parseDeviceId,
20
- parseError,
21
- updateFailureError,
22
- } from './functions.js'
23
-
24
- /**
25
- * The fan speed mapping is the one worth pinning hardest. Before #1310/#1326
26
- * the plugin sent the raw HomeKit percentage as a speed, so a slider at 100%
27
- * asked an 8-step fan for speed 100, and the Home app displayed speeds like
28
- * "7%". These tests cover the round trip on both step counts we ship.
29
- */
30
- describe('fan speed mapping', () => {
31
- it('maps the top of the slider to the fastest speed', () => {
32
- expect(hkPercentToFanSpeed(100, 8)).toBe(8)
33
- expect(hkPercentToFanSpeed(100, 12)).toBe(12)
34
- })
35
-
36
- it('never returns speed 0, which would mean "off" to the device', () => {
37
- // HomeKit sends 0 when the fan is switched off. Speed 0 is not a valid
38
- // speed, so the mapping clamps to the slowest instead.
39
- expect(hkPercentToFanSpeed(0, 8)).toBe(1)
40
- expect(hkPercentToFanSpeed(1, 8)).toBe(1)
41
- })
42
-
43
- it('never exceeds the number of steps the model has', () => {
44
- expect(hkPercentToFanSpeed(150, 8)).toBe(8)
45
- })
46
-
47
- it('round trips every speed on an 8 step model', () => {
48
- for (let speed = 1; speed <= 8; speed++) {
49
- expect(hkPercentToFanSpeed(fanSpeedToHkPercent(speed, 8), 8)).toBe(speed)
50
- }
51
- })
52
-
53
- it('round trips every speed on a 12 step model', () => {
54
- for (let speed = 1; speed <= 12; speed++) {
55
- expect(hkPercentToFanSpeed(fanSpeedToHkPercent(speed, 12), 12)).toBe(speed)
56
- }
57
- })
58
-
59
- it('reports a stopped fan as 0 percent', () => {
60
- expect(fanSpeedToHkPercent(0, 8)).toBe(0)
61
- expect(fanSpeedToHkPercent(undefined, 8)).toBe(0)
62
- })
63
-
64
- it('shows auto mode at the top of the slider', () => {
65
- // Chosen so the two paths disagree: without the auto case, speed 5 of 8
66
- // would map to 63%. An auto speed that happens to sit above the step count
67
- // would return 100 either way and would not prove the branch runs.
68
- expect(fanSpeedToHkPercent(5, 8, 5)).toBe(100)
69
- expect(fanSpeedToHkPercent(5, 8, undefined)).toBe(63)
70
- })
71
-
72
- it('does not treat auto as special when the model has no auto speed', () => {
73
- expect(fanSpeedToHkPercent(4, 8, undefined)).toBe(50)
74
- })
75
- })
76
-
77
- describe('parseDeviceId', () => {
78
- it('uppercases and strips punctuation the api does not use', () => {
79
- expect(parseDeviceId('ab:cd:ef')).toBe('AB:CD:EF')
80
- expect(parseDeviceId('ab cd-ef')).toBe('ABCDEF')
81
- })
82
-
83
- it('keeps the separators govee device ids actually contain', () => {
84
- // Colons and underscores are meaningful in a Govee device id, so the
85
- // cleaner must not strip them along with the rest.
86
- expect(parseDeviceId('AB:CD_EF')).toBe('AB:CD_EF')
87
- })
88
-
89
- it('accepts a non-string id without throwing', () => {
90
- expect(parseDeviceId(123456)).toBe('123456')
91
- })
92
- })
93
-
94
- describe('hex helpers', () => {
95
- it('splits a hex string into byte pairs', () => {
96
- expect(hexToTwoItems('aabbcc')).toEqual(['aa', 'bb', 'cc'])
97
- })
98
-
99
- it('reads a byte by its 1-based position, as the device handlers do', () => {
100
- // Callers index from 1 because they are reading a protocol spec, not an
101
- // array. Getting this off by one silently misreads every payload.
102
- const parts = hexToTwoItems('aa1b0164')
103
- expect(getTwoItemPosition(parts, 1)).toBe('aa')
104
- expect(getTwoItemPosition(parts, 2)).toBe('1b')
105
- expect(getTwoItemPosition(parts, 4)).toBe('64')
106
- })
107
-
108
- it('converts hex to decimal', () => {
109
- expect(hexToDecimal('64')).toBe(100)
110
- expect(hexToDecimal('ff')).toBe(255)
111
- })
112
-
113
- it('round trips base64 and hex', () => {
114
- const hex = '330501000000000000000000000000000000000037'
115
- expect(base64ToHex(hexToBase64(hex))).toBe(hex)
116
- })
117
- })
118
-
119
- describe('generateCodeFromHexValues', () => {
120
- it('builds a 20 byte packet', () => {
121
- // Every Govee BLE/IoT command is exactly 20 bytes: 19 of payload plus a
122
- // trailing XOR checksum.
123
- const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x05', '0x01']), 'base64')
124
- expect(packet).toHaveLength(20)
125
- })
126
-
127
- it('ends with an xor checksum over the first 19 bytes', () => {
128
- const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x05', '0x01']), 'base64')
129
- let expected = 0
130
- for (let i = 0; i < 19; i++) {
131
- expected ^= packet[i]
132
- }
133
- expect(packet[19]).toBe(expected)
134
- })
135
-
136
- it('pads short commands with zeroes rather than leaving them short', () => {
137
- const packet = Buffer.from(generateCodeFromHexValues(['0x33', '0x01']), 'base64')
138
- expect(packet.subarray(2, 19).every(b => b === 0)).toBe(true)
139
- })
140
- })
141
-
142
- describe('temperature conversion', () => {
143
- it('converts celsius to fahrenheit to one decimal place', () => {
144
- expect(cenToFar(0)).toBe(32)
145
- expect(cenToFar(100)).toBe(212)
146
- expect(cenToFar(21.5)).toBe(70.7)
147
- })
148
-
149
- it('converts fahrenheit to whole celsius', () => {
150
- expect(farToCen(32)).toBe(0)
151
- expect(farToCen(212)).toBe(100)
152
- })
153
-
154
- it('rounds to the nearest half', () => {
155
- expect(nearestHalf(21.24)).toBe(21)
156
- expect(nearestHalf(21.26)).toBe(21.5)
157
- expect(nearestHalf(21.75)).toBe(22)
158
- })
159
- })
160
-
161
- describe('misc helpers', () => {
162
- it('detects own properties only', () => {
163
- expect(hasProperty({ a: 1 }, 'a')).toBe(true)
164
- expect(hasProperty({}, 'toString')).toBe(false)
165
- })
166
-
167
- it('appends the first stack frame to an error message', () => {
168
- const err = new Error('boom')
169
- err.stack = 'Error: boom\n at thing (/a.js:1:1)'
170
- expect(parseError(err)).toContain('boom')
171
- expect(parseError(err)).toContain('at thing')
172
- })
173
-
174
- it('can be told to hide the stack for expected errors', () => {
175
- // Some errors are routine (a device being offline) and their stack is
176
- // noise in the log, so callers pass them in hideStack.
177
- const err = new Error('offline')
178
- err.stack = 'Error: offline\n at thing (/a.js:1:1)'
179
- expect(parseError(err, ['offline'])).toBe('offline')
180
- })
181
- })
182
-
183
- /**
184
- * Govee can accept a control request and still refuse the command, reporting it
185
- * only inside `capability.state`. That shape used to read as success, so the
186
- * device ignored the command while HomeKit kept showing the new state (#1324).
187
- */
188
- describe('describeControlFailure', () => {
189
- it('returns govee\'s own wording for the offline water timer in #1324', () => {
190
- const response = {
191
- requestId: 'test-control-turn-on',
192
- msg: 'Device is offline. Please check the Wi-Fi connection.',
193
- code: 400,
194
- capability: {
195
- type: 'devices.capabilities.on_off',
196
- instance: 'powerSwitch',
197
- state: {
198
- status: 'failure',
199
- errorCode: 400,
200
- errorMsg: 'Device is offline. Please check the Wi-Fi connection.',
201
- },
202
- value: 1,
203
- },
204
- }
205
-
206
- expect(describeControlFailure(response)).toBe('Device is offline. Please check the Wi-Fi connection.')
207
- })
208
-
209
- it('catches a refusal even when the response is 200 overall', () => {
210
- // The case the old code missed entirely: nothing at the top level says
211
- // anything went wrong.
212
- const response = {
213
- code: 200,
214
- msg: 'success',
215
- capability: { state: { status: 'failure', errorMsg: 'Device is busy' } },
216
- }
217
-
218
- expect(describeControlFailure(response)).toBe('Device is busy')
219
- })
220
-
221
- it('falls back to the top-level message when no errorMsg is given', () => {
222
- const response = {
223
- msg: 'Device is offline',
224
- capability: { state: { status: 'failure' } },
225
- }
226
-
227
- expect(describeControlFailure(response)).toBe('Device is offline')
228
- })
229
-
230
- it('still describes a refusal that carries no wording at all', () => {
231
- expect(describeControlFailure({ capability: { state: { status: 'failure' } } }))
232
- .toBe('the device rejected the command')
233
- })
234
-
235
- it('returns null when the command was accepted', () => {
236
- const accepted = {
237
- code: 200,
238
- msg: 'success',
239
- capability: { state: { status: 'success' } },
240
- }
241
-
242
- expect(describeControlFailure(accepted)).toBeNull()
243
- })
244
-
245
- it('returns null for responses with no capability state', () => {
246
- expect(describeControlFailure({ code: 200, msg: 'success' })).toBeNull()
247
- expect(describeControlFailure({})).toBeNull()
248
- expect(describeControlFailure(undefined)).toBeNull()
249
- })
250
- })
251
-
252
- /**
253
- * The bug (#1324): when a command had a Bluetooth fallback, every earlier
254
- * failure was thrown away and replaced with "no connection method available".
255
- * That is worse than vague — it is untrue, and its link sends the user off to
256
- * configure connection methods when the real answer was that Govee had said
257
- * the device was offline.
258
- */
259
- describe('updateFailureError', () => {
260
- it('reports govee\'s own refusal rather than the generic message', () => {
261
- const refusal = new Error('Device is offline. Please check the Wi-Fi connection.')
262
- const chosen = updateFailureError(refusal, 'no connection method available')
263
-
264
- expect(chosen.message).toBe('Device is offline. Please check the Wi-Fi connection.')
265
- })
266
-
267
- it('passes the original error through, so upstream flags survive', () => {
268
- const refusal = new Error('Device is offline. Please check the Wi-Fi connection.')
269
- refusal.deviceRefusal = true
270
-
271
- const chosen = updateFailureError(refusal, 'no connection method available')
272
-
273
- // Same object, not a copy - platform.js reads `deviceRefusal` off it to
274
- // decide whether to print a stack line.
275
- expect(chosen).toBe(refusal)
276
- expect(chosen.deviceRefusal).toBe(true)
277
- })
278
-
279
- it('falls back to the generic message only when nothing was tried', () => {
280
- expect(updateFailureError(undefined, 'no connection method available').message)
281
- .toBe('no connection method available')
282
- expect(updateFailureError(null, 'no connection method available').message)
283
- .toBe('no connection method available')
284
- })
285
-
286
- it('keeps a bluetooth timeout rather than masking it', () => {
287
- const bleFailure = new Error('BLE update failed: Connection timeout')
288
-
289
- expect(updateFailureError(bleFailure, 'no connection method available').message)
290
- .toBe('BLE update failed: Connection timeout')
291
- })
292
- })
@@ -1,187 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest'
2
-
3
- import { isFirstReport, logUnknownData, reportUnsupportedControl, resetReportedUnknowns } from './report-unknown.js'
4
-
5
- function makeAccessory(context = {}) {
6
- return {
7
- displayName: 'Test Device',
8
- context: {
9
- gvDeviceId: 'AA:BB:CC:DD',
10
- gvModel: 'H6102',
11
- firmware: '1.03.01',
12
- hardware: '2.00.01',
13
- ...context,
14
- },
15
- logWarn: vi.fn(),
16
- logDebug: vi.fn(),
17
- }
18
- }
19
-
20
- function reported(accessory) {
21
- return accessory.logWarn.mock.calls[0]?.[0] ?? ''
22
- }
23
-
24
- function payloadOf(accessory) {
25
- const line = reported(accessory)
26
- return JSON.parse(line.slice(line.indexOf('{')))
27
- }
28
-
29
- describe('reporting unrecognised data', () => {
30
- beforeEach(() => {
31
- resetReportedUnknowns()
32
- })
33
-
34
- it('includes everything needed to add support, without being asked for it', () => {
35
- const accessory = makeAccessory()
36
-
37
- logUnknownData(accessory, {
38
- kind: 'scene',
39
- source: 'AWS',
40
- raw: 'MwUBAQAAAAAAAAAAAAAAAAAAADY=',
41
- })
42
-
43
- const payload = payloadOf(accessory)
44
-
45
- // the three things that otherwise always need a follow-up question
46
- expect(payload.model).toBe('H6102')
47
- expect(payload.firmware).toBe('1.03.01')
48
- expect(payload.source).toBe('AWS')
49
- // and the data itself, in both the forms that get used
50
- expect(payload.raw).toBe('MwUBAQAAAAAAAAAAAAAAAAAAADY=')
51
- expect(payload.hex).toBe('3305010100000000000000000000000000000036')
52
- })
53
-
54
- it('tells the user the line is what to report', () => {
55
- const accessory = makeAccessory()
56
- logUnknownData(accessory, { kind: 'scene', source: 'BLE', raw: 'abc' })
57
-
58
- expect(reported(accessory)).toContain('please include this line if you report it')
59
- })
60
-
61
- it('is a warning the first time so it is actually seen', () => {
62
- const accessory = makeAccessory()
63
- logUnknownData(accessory, { kind: 'scene', source: 'AWS', raw: 'abc' })
64
-
65
- expect(accessory.logWarn).toHaveBeenCalledTimes(1)
66
- expect(accessory.logDebug).not.toHaveBeenCalled()
67
- })
68
-
69
- it('does not shout twice about the same payload', () => {
70
- const accessory = makeAccessory()
71
- const entry = { kind: 'scene', source: 'AWS', raw: 'abc' }
72
-
73
- logUnknownData(accessory, entry)
74
- logUnknownData(accessory, entry)
75
- logUnknownData(accessory, entry)
76
-
77
- // devices repeat their codes constantly, so this must not flood the log
78
- expect(accessory.logWarn).toHaveBeenCalledTimes(1)
79
- expect(accessory.logDebug).toHaveBeenCalledTimes(2)
80
- })
81
-
82
- it('still reports a genuinely different payload', () => {
83
- const accessory = makeAccessory()
84
-
85
- logUnknownData(accessory, { kind: 'scene', source: 'AWS', raw: 'abc' })
86
- logUnknownData(accessory, { kind: 'scene', source: 'AWS', raw: 'xyz' })
87
-
88
- expect(accessory.logWarn).toHaveBeenCalledTimes(2)
89
- })
90
-
91
- it('treats two different devices separately', () => {
92
- const one = makeAccessory({ gvDeviceId: 'DEVICE:1' })
93
- const two = makeAccessory({ gvDeviceId: 'DEVICE:2' })
94
-
95
- logUnknownData(one, { kind: 'scene', source: 'AWS', raw: 'abc' })
96
- logUnknownData(two, { kind: 'scene', source: 'AWS', raw: 'abc' })
97
-
98
- expect(one.logWarn).toHaveBeenCalledTimes(1)
99
- expect(two.logWarn).toHaveBeenCalledTimes(1)
100
- })
101
-
102
- it('leaves out fields the device has not told us', () => {
103
- const accessory = makeAccessory({ firmware: undefined, hardware: undefined })
104
- logUnknownData(accessory, { kind: 'scene', source: 'AWS', raw: 'abc' })
105
-
106
- const payload = payloadOf(accessory)
107
- expect(payload).not.toHaveProperty('firmware')
108
- expect(payload).not.toHaveProperty('hardware')
109
- expect(payload.model).toBe('H6102')
110
- })
111
-
112
- it('records an object payload rather than dropping it', () => {
113
- const accessory = makeAccessory()
114
- logUnknownData(accessory, { kind: 'payload', source: 'LAN', raw: { foo: 'bar' } })
115
-
116
- expect(payloadOf(accessory).raw).toBe('{"foo":"bar"}')
117
- })
118
-
119
- it('copes with a payload that is not base64', () => {
120
- const accessory = makeAccessory()
121
-
122
- expect(() => logUnknownData(accessory, {
123
- kind: 'scene',
124
- source: 'AWS',
125
- raw: 'not base64 !!',
126
- })).not.toThrow()
127
- expect(accessory.logWarn).toHaveBeenCalledTimes(1)
128
- })
129
-
130
- it('says nothing when there is no accessory', () => {
131
- expect(() => logUnknownData(undefined, { kind: 'scene', raw: 'abc' })).not.toThrow()
132
- })
133
-
134
- describe('a control that is not worked out yet', () => {
135
- it('names the control and what was asked for', () => {
136
- const accessory = makeAccessory()
137
- reportUnsupportedControl(accessory, { control: 'fan speed', value: 50 })
138
-
139
- const line = reported(accessory)
140
- expect(line).toContain('fan speed')
141
- expect(line).toContain('did not happen')
142
-
143
- const payload = JSON.parse(line.slice(line.indexOf('{')))
144
- expect(payload.model).toBe('H6102')
145
- expect(payload.firmware).toBe('1.03.01')
146
- expect(payload.requested).toBe(50)
147
- })
148
-
149
- it('asks the user to report it', () => {
150
- const accessory = makeAccessory()
151
- reportUnsupportedControl(accessory, { control: 'fan speed', value: 1 })
152
-
153
- expect(reported(accessory)).toContain('please report this line')
154
- })
155
-
156
- it('warns once per control, then keeps quiet', () => {
157
- const accessory = makeAccessory()
158
-
159
- reportUnsupportedControl(accessory, { control: 'fan speed', value: 25 })
160
- reportUnsupportedControl(accessory, { control: 'fan speed', value: 50 })
161
-
162
- expect(accessory.logWarn).toHaveBeenCalledTimes(1)
163
- expect(accessory.logDebug).toHaveBeenCalledTimes(1)
164
- })
165
-
166
- it('still reports a different control on the same device', () => {
167
- const accessory = makeAccessory()
168
-
169
- reportUnsupportedControl(accessory, { control: 'fan speed', value: 25 })
170
- reportUnsupportedControl(accessory, { control: 'ice size', value: 2 })
171
-
172
- expect(accessory.logWarn).toHaveBeenCalledTimes(2)
173
- })
174
-
175
- it('does not throw when there is no accessory', () => {
176
- expect(() => reportUnsupportedControl(undefined, { control: 'fan speed' })).not.toThrow()
177
- })
178
- })
179
-
180
- describe('isFirstReport', () => {
181
- it('is true once and false after', () => {
182
- expect(isFirstReport('a')).toBe(true)
183
- expect(isFirstReport('a')).toBe(false)
184
- expect(isFirstReport('b')).toBe(true)
185
- })
186
- })
187
- })