@homebridge-plugins/homebridge-govee 11.32.2-beta.7 → 11.32.2-beta.9
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 +2 -0
- package/package.json +1 -1
- package/CONTRIBUTING-STRUCTURE.md +0 -66
- package/lib/device/appliance-basic.test.js +0 -150
- package/lib/utils/appliance-capabilities.test.js +0 -31
- package/lib/utils/ble-timeouts.test.js +0 -44
- package/lib/utils/decode.test.js +0 -105
- package/lib/utils/device-capabilities.test.js +0 -62
- package/lib/utils/functions.test.js +0 -292
- package/lib/utils/report-unknown.test.js +0 -187
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,8 @@ 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
|
|
24
|
+
- docs: keep the structure notes outside the repository, since they are working notes rather than plugin documentation
|
|
23
25
|
|
|
24
26
|
## v11.32.1 (2026-08-02)
|
|
25
27
|
|
package/package.json
CHANGED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# How this plugin is put together, and how to extend it
|
|
2
|
-
|
|
3
|
-
This is the working reference for adding models and functionality. It exists so
|
|
4
|
-
that the next change follows the same shape as the last, whoever makes it.
|
|
5
|
-
|
|
6
|
-
## The three ways a model gets support
|
|
7
|
-
|
|
8
|
-
**1. It shares behaviour with a model already handled — alias it.**
|
|
9
|
-
Nothing new is written. `lib/device/index.js` maps the new model's key to the
|
|
10
|
-
existing handler:
|
|
11
|
-
|
|
12
|
-
```text
|
|
13
|
-
deviceHumidifierH7147: deviceHumidifierH7143,
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
This is the common case. Several Govee models are the same device with a
|
|
17
|
-
different name, and a handler copied per model is a handler that has to be fixed
|
|
18
|
-
per model.
|
|
19
|
-
|
|
20
|
-
**2. It is a new model of a known type, but the details are unknown — let it
|
|
21
|
-
say so.** Add it to the model list in `lib/utils/constants.js` and nothing else.
|
|
22
|
-
The dispatch falls back to `appliance-basic.js`, which offers on and off (that
|
|
23
|
-
command is identical across every Govee appliance) and makes every other control
|
|
24
|
-
report what it needs and then fail, so the owner sees it in the Home app, checks
|
|
25
|
-
the log, and finds a line to paste into an issue.
|
|
26
|
-
|
|
27
|
-
**3. It genuinely behaves differently — write a handler.**
|
|
28
|
-
Only when the controls or their commands really differ.
|
|
29
|
-
|
|
30
|
-
## Where model knowledge lives
|
|
31
|
-
|
|
32
|
-
Per-model differences belong in data, not in a copied file:
|
|
33
|
-
|
|
34
|
-
| File | Holds |
|
|
35
|
-
|---|---|
|
|
36
|
-
| `lib/utils/constants.js` | which models are which type |
|
|
37
|
-
| `lib/utils/device-capabilities.js` | protocol quirks — colour command, brightness scale, and firmware-dependent behaviour |
|
|
38
|
-
| `lib/utils/appliance-capabilities.js` | which controls a model actually has |
|
|
39
|
-
|
|
40
|
-
If a change means copying a file and editing two lines, it belongs in one of
|
|
41
|
-
these instead.
|
|
42
|
-
|
|
43
|
-
## Reporting anything unknown
|
|
44
|
-
|
|
45
|
-
Never log an unrecognised payload by hand. Use `lib/utils/report-unknown.js`:
|
|
46
|
-
|
|
47
|
-
- `logUnknownData` — something arrived that is not understood
|
|
48
|
-
- `reportUnsupportedControl` — the user asked for something not worked out yet
|
|
49
|
-
|
|
50
|
-
Both produce one line carrying the model, firmware, hardware, connection and
|
|
51
|
-
payload, so a single copy-paste is enough to act on. Both warn once and then
|
|
52
|
-
drop to debug, so a device repeating itself does not flood the log.
|
|
53
|
-
|
|
54
|
-
## Things worth knowing before changing appliance code
|
|
55
|
-
|
|
56
|
-
- **The power command is universal.** `33 01 01` on, `33 01 00` off, on every
|
|
57
|
-
appliance. Safe to send for an untested model.
|
|
58
|
-
- **Frames are 20 bytes with an XOR checksum** — byte 20 is the XOR of bytes
|
|
59
|
-
1–19. All 407 shipped appliance commands have been verified against this.
|
|
60
|
-
- **A control that silently does nothing is worse than one that fails.** Failing
|
|
61
|
-
shows up in the Home app and sends the owner to the log.
|
|
62
|
-
- **Do not remove a control a model has offered for a long time** on evidence
|
|
63
|
-
alone. Someone may be using it. Confirm with an owner first.
|
|
64
|
-
- **HomeKit tiles have requirements.** A humidifier must report a current
|
|
65
|
-
humidity, which many Govee humidifiers never send — hence the plain fan tile
|
|
66
|
-
by default, and the richer one only when the device says it has the sensor.
|
|
@@ -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
|
-
})
|
package/lib/utils/decode.test.js
DELETED
|
@@ -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
|
-
})
|