@homebridge-plugins/homebridge-govee 11.32.2-beta.4 → 11.32.2-beta.6
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/lib/device/appliance-basic.js +280 -0
- package/lib/device/appliance-basic.test.js +150 -0
- package/lib/device/index.js +12 -0
- package/lib/device/template.js +8 -1
- package/lib/platform.js +8 -6
- package/lib/utils/constants.js +6 -6
- package/lib/utils/report-unknown.js +45 -0
- package/lib/utils/report-unknown.test.js +47 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,8 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
17
17
|
- feat: work out for itself whether a heater reports the room temperature, and add the H7137 and H713E
|
|
18
18
|
- feat: add support for 85 more light models, covering everything currently sold that was missing
|
|
19
19
|
- feat: add 24 more light models, three thermo-hygrometers and a leak detector
|
|
20
|
+
- feat: add the H7103 and H7112 fans and the H5085 plug, with unknown controls saying so instead of guessing
|
|
21
|
+
- feat: give every appliance type a starting handler, so a new model works for on and off and asks for the rest
|
|
20
22
|
|
|
21
23
|
## v11.32.1 (2026-08-02)
|
|
22
24
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { base64ToHex, getTwoItemPosition, hexToTwoItems, parseError } from '../utils/functions.js'
|
|
2
|
+
import platformLang from '../utils/lang-en.js'
|
|
3
|
+
import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknown.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A starting point for any appliance whose power command is known but whose
|
|
7
|
+
* other controls are not.
|
|
8
|
+
*
|
|
9
|
+
* Govee uses the same on/off command for every appliance the plugin handles, so
|
|
10
|
+
* power can be offered with confidence for a model nobody has tested. The
|
|
11
|
+
* settings that differ per model - speeds, modes, sizes - are the risky part,
|
|
12
|
+
* and getting one wrong is worse than not offering it. So those controls appear
|
|
13
|
+
* but report what they need and fail the request, rather than sending a guess.
|
|
14
|
+
*
|
|
15
|
+
* Failing rather than doing nothing is deliberate: HomeKit shows the control as
|
|
16
|
+
* not responding, which is what sends the owner to the log, where they find a
|
|
17
|
+
* line to paste into an issue. That turns every owner of an untested model into
|
|
18
|
+
* the source of the data needed to finish it.
|
|
19
|
+
*
|
|
20
|
+
* Once that data arrives the model gets a proper handler and stops using this.
|
|
21
|
+
*/
|
|
22
|
+
class ApplianceBasic {
|
|
23
|
+
constructor(platform, accessory, config) {
|
|
24
|
+
// Set up variables from the platform
|
|
25
|
+
this.hapChar = platform.api.hap.Characteristic
|
|
26
|
+
this.hapErr = platform.api.hap.HapStatusError
|
|
27
|
+
this.hapServ = platform.api.hap.Service
|
|
28
|
+
this.platform = platform
|
|
29
|
+
|
|
30
|
+
// Set up variables from the accessory
|
|
31
|
+
this.accessory = accessory
|
|
32
|
+
this.config = config
|
|
33
|
+
|
|
34
|
+
// Some appliances can have a richer tile, but only if the device reports
|
|
35
|
+
// the reading that tile needs - HomeKit's humidifier, for example, has to
|
|
36
|
+
// show a current humidity, and plenty of Govee humidifiers never send one.
|
|
37
|
+
// Where Govee tells us the device has that sensor, use the better tile;
|
|
38
|
+
// otherwise fall back to the plain one, which needs nothing extra.
|
|
39
|
+
const hasSensor = config.richCapability
|
|
40
|
+
&& !!accessory.context.openApiCapabilities?.[config.richCapability]
|
|
41
|
+
const serviceName = (config.richService && hasSensor) ? config.richService : config.service
|
|
42
|
+
this.usingRichService = serviceName === config.richService
|
|
43
|
+
|
|
44
|
+
// Drop the other service if this device has switched between the two
|
|
45
|
+
const otherName = this.usingRichService ? config.service : config.richService
|
|
46
|
+
if (otherName && this.hapServ[otherName]) {
|
|
47
|
+
const stale = this.accessory.getService(this.hapServ[otherName])
|
|
48
|
+
if (stale) {
|
|
49
|
+
this.accessory.removeService(stale)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const serviceType = this.hapServ[serviceName]
|
|
54
|
+
this.service = this.accessory.getService(serviceType)
|
|
55
|
+
|| this.accessory.addService(serviceType)
|
|
56
|
+
|
|
57
|
+
// The plain Fan service switches on a boolean `On`, while Fanv2 and
|
|
58
|
+
// AirPurifier use `Active`. Follow whichever this service uses, so these
|
|
59
|
+
// tiles behave like the hand-written handlers for the same kind of device.
|
|
60
|
+
const usesOn = serviceName === 'Fan'
|
|
61
|
+
this.powerChar = usesOn ? this.hapChar.On : this.hapChar.Active
|
|
62
|
+
this.powerOn = usesOn ? true : 1
|
|
63
|
+
this.powerOff = usesOn ? false : 0
|
|
64
|
+
|
|
65
|
+
// Add the set handler to the power characteristic
|
|
66
|
+
this.service
|
|
67
|
+
.getCharacteristic(this.powerChar)
|
|
68
|
+
.onSet(async value => this.internalStateUpdate(value))
|
|
69
|
+
this.cacheState = this.service.getCharacteristic(this.powerChar).value ? 'on' : 'off'
|
|
70
|
+
|
|
71
|
+
// Some services need a current-state characteristic to look right in the
|
|
72
|
+
// Home app. It belongs to the richer tile, so it is skipped when a device
|
|
73
|
+
// has fallen back to the plain fan for want of a sensor.
|
|
74
|
+
const fellBackToPlain = !!config.richService && !this.usingRichService
|
|
75
|
+
if (config.currentStateChar && !fellBackToPlain) {
|
|
76
|
+
this.currentStateChar = this.hapChar[config.currentStateChar]
|
|
77
|
+
this.service.updateCharacteristic(
|
|
78
|
+
this.currentStateChar,
|
|
79
|
+
this.cacheState === 'on' ? config.currentStateOn : config.currentStateOff,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Offer the variable control so the device can be asked about it, but make
|
|
84
|
+
// it clear it does nothing yet rather than sending a guessed command. The
|
|
85
|
+
// richer services name this control differently, so follow the service.
|
|
86
|
+
const variableChar = this.usingRichService && config.richVariableChar
|
|
87
|
+
? config.richVariableChar
|
|
88
|
+
: config.variableChar
|
|
89
|
+
if (variableChar) {
|
|
90
|
+
this.variableChar = this.hapChar[variableChar]
|
|
91
|
+
this.service
|
|
92
|
+
.getCharacteristic(this.variableChar)
|
|
93
|
+
.onSet(async value => this.internalVariableUpdate(value))
|
|
94
|
+
this.cacheVariable = this.service.getCharacteristic(this.variableChar).value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Output the customised options to the log
|
|
98
|
+
const opts = JSON.stringify({})
|
|
99
|
+
platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
|
|
100
|
+
this.accessory.log(`on and off are supported for this model, the ${config.variableLabel || 'other settings'} are not known yet`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async internalStateUpdate(value) {
|
|
104
|
+
try {
|
|
105
|
+
const newValue = value ? 'on' : 'off'
|
|
106
|
+
|
|
107
|
+
// Don't continue if the new value is the same as before
|
|
108
|
+
if (this.cacheState === newValue) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// This power command is the same for every govee appliance, so it is safe
|
|
113
|
+
// to send for a model that has not been tested
|
|
114
|
+
await this.platform.sendDeviceUpdate(this.accessory, {
|
|
115
|
+
cmd: 'ptReal',
|
|
116
|
+
value: value ? 'MwEBAAAAAAAAAAAAAAAAAAAAADM=' : 'MwEAAAAAAAAAAAAAAAAAAAAAADI=',
|
|
117
|
+
openApi: this.accessory.context.openApiCapabilities?.powerSwitch
|
|
118
|
+
? { instance: 'powerSwitch', capabilityType: 'devices.capabilities.on_off', value: value ? 1 : 0 }
|
|
119
|
+
: undefined,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Cache the new state and log if appropriate
|
|
123
|
+
this.cacheState = newValue
|
|
124
|
+
if (this.currentStateChar) {
|
|
125
|
+
this.service.updateCharacteristic(
|
|
126
|
+
this.currentStateChar,
|
|
127
|
+
value ? this.config.currentStateOn : this.config.currentStateOff,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
this.accessory.log(`${platformLang.curState} [${newValue}]`)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Catch any errors during the process
|
|
133
|
+
this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
|
|
134
|
+
|
|
135
|
+
// Throw a 'no response' error and set a timeout to revert this after 2 seconds
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
138
|
+
}, 2000)
|
|
139
|
+
throw new this.hapErr(-70402)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async internalVariableUpdate(value) {
|
|
144
|
+
// 0 is the control following the device being turned off, not a request
|
|
145
|
+
if (value === 0) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
reportUnsupportedControl(this.accessory, {
|
|
150
|
+
control: this.config.variableLabel || 'setting',
|
|
151
|
+
value,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Put the control back, and fail the request so HomeKit shows it as not
|
|
155
|
+
// responding - that is what sends the owner to the log
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
this.service.updateCharacteristic(this.variableChar, this.cacheVariable)
|
|
158
|
+
}, 2000)
|
|
159
|
+
throw new this.hapErr(-70402)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
externalUpdate(params) {
|
|
163
|
+
// Feed the sensor reading through when the richer tile is in use - HomeKit
|
|
164
|
+
// needs it, and it is the reason that tile was chosen
|
|
165
|
+
if (this.usingRichService && this.config.richReadingChar && params.humidity !== undefined) {
|
|
166
|
+
const reading = Number(params.humidity)
|
|
167
|
+
if (Number.isFinite(reading) && reading !== this.cacheReading) {
|
|
168
|
+
this.cacheReading = reading
|
|
169
|
+
this.service.updateCharacteristic(this.hapChar[this.config.richReadingChar], reading)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Power is the one thing that can be read back with confidence
|
|
174
|
+
if (params.state && params.state !== this.cacheState) {
|
|
175
|
+
this.cacheState = params.state
|
|
176
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
177
|
+
if (this.currentStateChar) {
|
|
178
|
+
this.service.updateCharacteristic(
|
|
179
|
+
this.currentStateChar,
|
|
180
|
+
this.cacheState === 'on' ? this.config.currentStateOn : this.config.currentStateOff,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Everything else gets reported so the model can be finished off
|
|
187
|
+
;(params.commands || []).forEach((command) => {
|
|
188
|
+
const hexString = base64ToHex(command)
|
|
189
|
+
const hexParts = hexToTwoItems(hexString)
|
|
190
|
+
|
|
191
|
+
if (getTwoItemPosition(hexParts, 1) !== 'aa') {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The power report is understood, anything else is worth sending in
|
|
196
|
+
if (getTwoItemPosition(hexParts, 2) === '01') {
|
|
197
|
+
const newState = getTwoItemPosition(hexParts, 3) === '01' ? 'on' : 'off'
|
|
198
|
+
if (this.cacheState !== newState) {
|
|
199
|
+
this.cacheState = newState
|
|
200
|
+
this.service.updateCharacteristic(this.hapChar.Active, this.cacheState === 'on' ? 1 : 0)
|
|
201
|
+
this.accessory.log(`${platformLang.curState} [${this.cacheState}]`)
|
|
202
|
+
}
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
logUnknownData(this.accessory, {
|
|
207
|
+
kind: 'scene',
|
|
208
|
+
source: params.source,
|
|
209
|
+
raw: command,
|
|
210
|
+
hex: hexString,
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a basic handler for a kind of appliance.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} config how this kind of appliance should appear
|
|
220
|
+
* @returns {Function} a handler class the platform can construct
|
|
221
|
+
*/
|
|
222
|
+
export function makeBasicAppliance(config) {
|
|
223
|
+
return class extends ApplianceBasic {
|
|
224
|
+
constructor(platform, accessory) {
|
|
225
|
+
super(platform, accessory, config)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Fan-shaped appliances: a speed slider that is not worked out yet
|
|
231
|
+
export const deviceFanBasic = makeBasicAppliance({
|
|
232
|
+
service: 'Fanv2',
|
|
233
|
+
variableChar: 'RotationSpeed',
|
|
234
|
+
variableLabel: 'fan speed',
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Ice makers reuse the fan tile, with the slider standing in for ice size
|
|
238
|
+
export const deviceIceMakerBasic = makeBasicAppliance({
|
|
239
|
+
service: 'Fanv2',
|
|
240
|
+
variableChar: 'RotationSpeed',
|
|
241
|
+
variableLabel: 'ice size',
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// Humidifiers get the plain fan tile by default, because HomeKit's humidifier
|
|
245
|
+
// has to show a current humidity and many Govee humidifiers never report one.
|
|
246
|
+
// A model that does report it gets the proper humidifier tile instead.
|
|
247
|
+
export const deviceHumidifierBasic = makeBasicAppliance({
|
|
248
|
+
service: 'Fan',
|
|
249
|
+
variableChar: 'RotationSpeed',
|
|
250
|
+
variableLabel: 'mist level',
|
|
251
|
+
richService: 'HumidifierDehumidifier',
|
|
252
|
+
richCapability: 'sensorHumidity',
|
|
253
|
+
richVariableChar: 'RelativeHumidityHumidifierThreshold',
|
|
254
|
+
richReadingChar: 'CurrentRelativeHumidity',
|
|
255
|
+
currentStateChar: 'CurrentHumidifierDehumidifierState',
|
|
256
|
+
currentStateOn: 2, // humidifying
|
|
257
|
+
currentStateOff: 0, // inactive
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
export const deviceDehumidifierBasic = makeBasicAppliance({
|
|
261
|
+
service: 'Fan',
|
|
262
|
+
variableChar: 'RotationSpeed',
|
|
263
|
+
variableLabel: 'fan speed',
|
|
264
|
+
richService: 'HumidifierDehumidifier',
|
|
265
|
+
richCapability: 'sensorHumidity',
|
|
266
|
+
richVariableChar: 'RelativeHumidityDehumidifierThreshold',
|
|
267
|
+
richReadingChar: 'CurrentRelativeHumidity',
|
|
268
|
+
currentStateChar: 'CurrentHumidifierDehumidifierState',
|
|
269
|
+
currentStateOn: 3, // dehumidifying
|
|
270
|
+
currentStateOff: 0, // inactive
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
export const devicePurifierBasic = makeBasicAppliance({
|
|
274
|
+
service: 'AirPurifier',
|
|
275
|
+
variableChar: 'RotationSpeed',
|
|
276
|
+
variableLabel: 'fan speed',
|
|
277
|
+
currentStateChar: 'CurrentAirPurifierState',
|
|
278
|
+
currentStateOn: 2, // purifying air
|
|
279
|
+
currentStateOff: 0, // inactive
|
|
280
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
})
|
package/lib/device/index.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deviceDehumidifierBasic,
|
|
3
|
+
deviceFanBasic,
|
|
4
|
+
deviceHumidifierBasic,
|
|
5
|
+
deviceIceMakerBasic,
|
|
6
|
+
devicePurifierBasic,
|
|
7
|
+
} from './appliance-basic.js'
|
|
1
8
|
import deviceCoolerSingle from './cooler-single.js'
|
|
2
9
|
import deviceDehumidifierH7150 from './dehumidifier-H7150.js'
|
|
3
10
|
import deviceDehumidifierH7151 from './dehumidifier-H7151.js'
|
|
@@ -73,6 +80,8 @@ export default {
|
|
|
73
80
|
deviceDehumidifierH7152,
|
|
74
81
|
deviceDiffuserH7161,
|
|
75
82
|
deviceDiffuserH7162,
|
|
83
|
+
deviceDehumidifierBasic,
|
|
84
|
+
deviceFanBasic,
|
|
76
85
|
deviceFanH1310,
|
|
77
86
|
deviceFanH1370,
|
|
78
87
|
deviceFanR1310: deviceFanH1310, // R1310 is the newer naming of the H1310
|
|
@@ -87,6 +96,7 @@ export default {
|
|
|
87
96
|
deviceHeater1A,
|
|
88
97
|
deviceHeater1B,
|
|
89
98
|
deviceHeater2,
|
|
99
|
+
deviceHumidifierBasic,
|
|
90
100
|
deviceHumidifierH714E,
|
|
91
101
|
deviceHumidifierH7140,
|
|
92
102
|
deviceHumidifierH7141,
|
|
@@ -97,6 +107,7 @@ export default {
|
|
|
97
107
|
deviceHumidifierH7148,
|
|
98
108
|
deviceHumidifierH7149,
|
|
99
109
|
deviceHumidifierH7160,
|
|
110
|
+
deviceIceMakerBasic,
|
|
100
111
|
deviceIceMakerH7172,
|
|
101
112
|
deviceIceMakerH717D,
|
|
102
113
|
deviceIceMakerH8120,
|
|
@@ -108,6 +119,7 @@ export default {
|
|
|
108
119
|
deviceOutletDouble,
|
|
109
120
|
deviceOutletSingle,
|
|
110
121
|
deviceOutletTriple,
|
|
122
|
+
devicePurifierBasic,
|
|
111
123
|
devicePurifierH712C,
|
|
112
124
|
devicePurifierH7120,
|
|
113
125
|
devicePurifierH7121,
|
package/lib/device/template.js
CHANGED
|
@@ -20,7 +20,14 @@ export default class {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
externalUpdate(params) {
|
|
23
|
-
|
|
23
|
+
// These are the models actively being worked out, so every payload is worth
|
|
24
|
+
// reporting in the full form rather than as a bare dump - it is the whole
|
|
25
|
+
// reason the accessory exists at this stage
|
|
26
|
+
logUnknownData(this.accessory, {
|
|
27
|
+
kind: 'payload',
|
|
28
|
+
source: params.source,
|
|
29
|
+
raw: params,
|
|
30
|
+
})
|
|
24
31
|
|
|
25
32
|
// Check for some other scene/mode change
|
|
26
33
|
;(params.commands || []).forEach((command) => {
|
package/lib/platform.js
CHANGED
|
@@ -1236,8 +1236,10 @@ export default class {
|
|
|
1236
1236
|
doAWSPolling = true
|
|
1237
1237
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1238
1238
|
} else if (platformConsts.models.fan.includes(device.model)) {
|
|
1239
|
-
// Device is a fan
|
|
1240
|
-
|
|
1239
|
+
// Device is a fan. Models whose speeds have not been worked out yet fall
|
|
1240
|
+
// back to the basic handler, which offers on and off and asks for what
|
|
1241
|
+
// it needs rather than leaving the device unsupported altogether
|
|
1242
|
+
devInstance = deviceTypes[`deviceFan${device.model}`] || deviceTypes.deviceFanBasic
|
|
1241
1243
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1242
1244
|
} else if (platformConsts.models.heater1.includes(device.model)) {
|
|
1243
1245
|
// Some of these heaters report the room temperature and some do not,
|
|
@@ -1263,16 +1265,16 @@ export default class {
|
|
|
1263
1265
|
} else if (platformConsts.models.humidifier.includes(device.model)) {
|
|
1264
1266
|
// Device is a humidifier
|
|
1265
1267
|
doAWSPolling = true
|
|
1266
|
-
devInstance = deviceTypes[`deviceHumidifier${device.model}`]
|
|
1268
|
+
devInstance = deviceTypes[`deviceHumidifier${device.model}`] || deviceTypes.deviceHumidifierBasic
|
|
1267
1269
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1268
1270
|
} else if (platformConsts.models.dehumidifier.includes(device.model)) {
|
|
1269
1271
|
// Device is a dehumidifier
|
|
1270
|
-
devInstance = deviceTypes[`deviceDehumidifier${device.model}`]
|
|
1272
|
+
devInstance = deviceTypes[`deviceDehumidifier${device.model}`] || deviceTypes.deviceDehumidifierBasic
|
|
1271
1273
|
doAWSPolling = true
|
|
1272
1274
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1273
1275
|
} else if (platformConsts.models.purifier.includes(device.model)) {
|
|
1274
1276
|
// Device is a purifier
|
|
1275
|
-
devInstance = deviceTypes[`devicePurifier${device.model}`]
|
|
1277
|
+
devInstance = deviceTypes[`devicePurifier${device.model}`] || deviceTypes.devicePurifierBasic
|
|
1276
1278
|
doAWSPolling = true
|
|
1277
1279
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1278
1280
|
} else if (platformConsts.models.diffuser.includes(device.model)) {
|
|
@@ -1294,7 +1296,7 @@ export default class {
|
|
|
1294
1296
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1295
1297
|
} else if (platformConsts.models.iceMaker.includes(device.model)) {
|
|
1296
1298
|
// Device is an ice maker
|
|
1297
|
-
devInstance = deviceTypes[`deviceIceMaker${device.model}`]
|
|
1299
|
+
devInstance = deviceTypes[`deviceIceMaker${device.model}`] || deviceTypes.deviceIceMakerBasic
|
|
1298
1300
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1299
1301
|
} else if (platformConsts.models.template.includes(device.model)) {
|
|
1300
1302
|
// Device is a work-in-progress
|
package/lib/utils/constants.js
CHANGED
|
@@ -621,7 +621,7 @@ export default {
|
|
|
621
621
|
// H5901 is a water timer, controlled only through the OpenAPI - it has no
|
|
622
622
|
// usable AWS, BLE or LAN path, so it needs an API key configured. Set
|
|
623
623
|
// showAs to 'valve' or 'tap' for a more fitting HomeKit tile (#1324)
|
|
624
|
-
switchSingle: ['H5001', 'H5080', 'H5081', 'H5083', 'H5086', 'H5901', 'H7014'],
|
|
624
|
+
switchSingle: ['H5001', 'H5080', 'H5081', 'H5083', 'H5085', 'H5086', 'H5901', 'H7014'],
|
|
625
625
|
switchDouble: ['H5082', 'H5089'],
|
|
626
626
|
switchTriple: ['H5160'],
|
|
627
627
|
sensorLeak: ['H5054', 'H5058', 'H5059', 'H5830'],
|
|
@@ -660,14 +660,14 @@ export default {
|
|
|
660
660
|
sensorThermo4: ['H5198'],
|
|
661
661
|
sensorMonitor: ['H5106'],
|
|
662
662
|
sensorCO2: ['H5140'], // CO2 + temp + humidity monitor, AWS opcode 0x0A — closes #1179
|
|
663
|
-
fan: ['H1310', 'H1370', 'H7100', 'H7101', 'H7102', 'H7105', 'H7106', 'H7107', 'H7111', 'R1310'],
|
|
663
|
+
fan: ['H1310', 'H1370', 'H7100', 'H7101', 'H7102', 'H7103', 'H7105', 'H7106', 'H7107', 'H7111', 'H7112', 'R1310'],
|
|
664
664
|
heater1: ['H7130', 'H713A', 'H713B', 'H713C', 'H7137', 'H713E'],
|
|
665
665
|
heater2: ['H7131', 'H7132', 'H7133', 'H7134', 'H7135'],
|
|
666
666
|
dehumidifier: ['H7150', 'H7151', 'H7152'],
|
|
667
|
-
humidifier: ['H7140', 'H7141', 'H7142', 'H7143', 'H7145', 'H7147', 'H7148', 'H7149', 'H714E', 'H7160'],
|
|
667
|
+
humidifier: ['H7140', 'H7141', 'H7142', 'H7143', 'H7145', 'H7146', 'H7147', 'H7148', 'H7149', 'H714E', 'H7160'],
|
|
668
668
|
purifier: ['H7120', 'H7121', 'H7122', 'H7123', 'H7124', 'H7126', 'H7127', 'H7128', 'H7129', 'H712C'],
|
|
669
669
|
diffuser: ['H7161', 'H7162'],
|
|
670
|
-
iceMaker: ['H7172', 'H717D', 'H8120', 'H8121', 'H8122'],
|
|
670
|
+
iceMaker: ['H7172', 'H717D', 'H8102', 'H8120', 'H8121', 'H8122', 'H8131'],
|
|
671
671
|
sensorButton: ['H5122'],
|
|
672
672
|
sensorContact: ['H5123'],
|
|
673
673
|
sensorPresence: ['H5127'],
|
|
@@ -679,8 +679,8 @@ export default {
|
|
|
679
679
|
'H5024', // https://github.com/homebridge-plugins/homebridge-govee/issues/835
|
|
680
680
|
'H5042', // https://github.com/homebridge-plugins/homebridge-govee/issues/849
|
|
681
681
|
'H5043', // https://github.com/homebridge-plugins/homebridge-govee/issues/558
|
|
682
|
-
|
|
683
|
-
|
|
682
|
+
// https://github.com/homebridge-plugins/homebridge-govee/issues/951
|
|
683
|
+
// https://github.com/homebridge-plugins/homebridge-govee/issues/1117
|
|
684
684
|
'H5121', // https://github.com/homebridge-plugins/homebridge-govee/issues/913
|
|
685
685
|
'H5124', // https://github.com/homebridge-plugins/homebridge-govee/issues/1119
|
|
686
686
|
'H5126', // https://github.com/homebridge-plugins/homebridge-govee/issues/910
|
|
@@ -43,6 +43,51 @@ export function isFirstReport(fingerprint) {
|
|
|
43
43
|
return true
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Report a control the plugin knows the device has, but does not yet know the
|
|
48
|
+
* command for, and then fail the request.
|
|
49
|
+
*
|
|
50
|
+
* This is how a model gets added before every last detail is known. The things
|
|
51
|
+
* that are certain - usually power - work straight away, and anything still
|
|
52
|
+
* unknown says so out loud instead of pretending to work. Failing the request
|
|
53
|
+
* matters: HomeKit shows the control as not responding, which sends the owner
|
|
54
|
+
* to the log, where they find the line to paste into an issue. A control that
|
|
55
|
+
* silently did nothing would just look broken.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} accessory the accessory the control belongs to
|
|
58
|
+
* @param {object} details
|
|
59
|
+
* @param {string} details.control what was asked for, eg 'fan speed'
|
|
60
|
+
* @param {*} [details.value] the value HomeKit asked for
|
|
61
|
+
* @returns {string} the message that was logged, mainly for tests
|
|
62
|
+
*/
|
|
63
|
+
export function reportUnsupportedControl(accessory, { control, value } = {}) {
|
|
64
|
+
const context = accessory?.context || {}
|
|
65
|
+
const report = {
|
|
66
|
+
model: context.gvModel,
|
|
67
|
+
firmware: context.firmware,
|
|
68
|
+
hardware: context.hardware,
|
|
69
|
+
control,
|
|
70
|
+
requested: value,
|
|
71
|
+
}
|
|
72
|
+
Object.keys(report).forEach((key) => {
|
|
73
|
+
if (report[key] === undefined || report[key] === null || report[key] === '') {
|
|
74
|
+
delete report[key]
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const line = `the ${control} for this model is not known yet, so this did not happen - please report this line so it can be added: ${JSON.stringify(report)}`
|
|
79
|
+
const fingerprint = `${context.gvDeviceId || accessory?.displayName}|unsupported|${control}`
|
|
80
|
+
|
|
81
|
+
if (alreadyReported.has(fingerprint)) {
|
|
82
|
+
accessory?.logDebug?.(line)
|
|
83
|
+
} else {
|
|
84
|
+
alreadyReported.add(fingerprint)
|
|
85
|
+
accessory?.logWarn?.(line)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return line
|
|
89
|
+
}
|
|
90
|
+
|
|
46
91
|
/**
|
|
47
92
|
* Log an unrecognised payload with everything needed to add support for it.
|
|
48
93
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { isFirstReport, logUnknownData, resetReportedUnknowns } from './report-unknown.js'
|
|
3
|
+
import { isFirstReport, logUnknownData, reportUnsupportedControl, resetReportedUnknowns } from './report-unknown.js'
|
|
4
4
|
|
|
5
5
|
function makeAccessory(context = {}) {
|
|
6
6
|
return {
|
|
@@ -131,6 +131,52 @@ describe('reporting unrecognised data', () => {
|
|
|
131
131
|
expect(() => logUnknownData(undefined, { kind: 'scene', raw: 'abc' })).not.toThrow()
|
|
132
132
|
})
|
|
133
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
|
+
|
|
134
180
|
describe('isFirstReport', () => {
|
|
135
181
|
it('is true once and false after', () => {
|
|
136
182
|
expect(isFirstReport('a')).toBe(true)
|
package/package.json
CHANGED