@homebridge-plugins/homebridge-govee 11.32.2-beta.5 → 11.32.2-beta.7
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/CONTRIBUTING-STRUCTURE.md +66 -0
- package/lib/device/appliance-basic.js +280 -0
- package/lib/device/appliance-basic.test.js +150 -0
- package/lib/device/index.js +25 -29
- package/lib/platform.js +4 -4
- package/lib/utils/constants.js +2 -2
- package/package.json +1 -1
- package/lib/device/diffuser-H7162.js +0 -129
- package/lib/device/fan-H7102.js +0 -290
- package/lib/device/fan-H7106.js +0 -290
- package/lib/device/fan-basic.js +0 -143
- package/lib/device/humidifier-H7145.js +0 -193
- package/lib/device/humidifier-H7147.js +0 -193
- package/lib/device/humidifier-H7148.js +0 -193
- package/lib/device/humidifier-H7149.js +0 -193
- package/lib/device/humidifier-H714E.js +0 -193
- package/lib/device/ice-maker-H717D.js +0 -224
- package/lib/device/ice-maker-H8120.js +0 -224
- package/lib/device/purifier-H7127.js +0 -365
- package/lib/device/purifier-H7128.js +0 -365
- package/lib/device/purifier-H7129.js +0 -365
- package/lib/device/purifier-H712C.js +0 -365
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,8 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
|
|
|
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
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
|
|
22
|
+
- refactor: point models that behave identically at one shared handler instead of copies of the same file
|
|
21
23
|
|
|
22
24
|
## v11.32.1 (2026-08-02)
|
|
23
25
|
|
|
@@ -0,0 +1,66 @@
|
|
|
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.
|
|
@@ -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,36 +1,32 @@
|
|
|
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'
|
|
4
11
|
import deviceDehumidifierH7152 from './dehumidifier-H7152.js'
|
|
5
12
|
import deviceDiffuserH7161 from './diffuser-H7161.js'
|
|
6
|
-
import deviceDiffuserH7162 from './diffuser-H7162.js'
|
|
7
|
-
import deviceFanBasic from './fan-basic.js'
|
|
8
13
|
import deviceFanH1310 from './fan-H1310.js'
|
|
9
14
|
import deviceFanH1370 from './fan-H1370.js'
|
|
10
15
|
import deviceFanH7100 from './fan-H7100.js'
|
|
11
16
|
import deviceFanH7101 from './fan-H7101.js'
|
|
12
|
-
import deviceFanH7102 from './fan-H7102.js'
|
|
13
17
|
import deviceFanH7105 from './fan-H7105.js'
|
|
14
|
-
import deviceFanH7106 from './fan-H7106.js'
|
|
15
18
|
import deviceFanH7107 from './fan-H7107.js'
|
|
16
19
|
import deviceFanH7111 from './fan-H7111.js'
|
|
17
20
|
import deviceHeater1A from './heater1a.js'
|
|
18
21
|
import deviceHeater1B from './heater1b.js'
|
|
19
22
|
import deviceHeater2 from './heater2.js'
|
|
20
23
|
import deviceHeaterSingle from './heater-single.js'
|
|
21
|
-
import deviceHumidifierH714E from './humidifier-H714E.js'
|
|
22
24
|
import deviceHumidifierH7140 from './humidifier-H7140.js'
|
|
23
25
|
import deviceHumidifierH7141 from './humidifier-H7141.js'
|
|
24
26
|
import deviceHumidifierH7142 from './humidifier-H7142.js'
|
|
25
27
|
import deviceHumidifierH7143 from './humidifier-H7143.js'
|
|
26
|
-
import deviceHumidifierH7145 from './humidifier-H7145.js'
|
|
27
|
-
import deviceHumidifierH7147 from './humidifier-H7147.js'
|
|
28
|
-
import deviceHumidifierH7148 from './humidifier-H7148.js'
|
|
29
|
-
import deviceHumidifierH7149 from './humidifier-H7149.js'
|
|
30
28
|
import deviceHumidifierH7160 from './humidifier-H7160.js'
|
|
31
|
-
import deviceIceMakerH717D from './ice-maker-H717D.js'
|
|
32
29
|
import deviceIceMakerH7172 from './ice-maker-H7172.js'
|
|
33
|
-
import deviceIceMakerH8120 from './ice-maker-H8120.js'
|
|
34
30
|
import deviceIceMakerH8121 from './ice-maker-H8121.js'
|
|
35
31
|
import deviceIceMakerH8122 from './ice-maker-H8122.js'
|
|
36
32
|
import deviceKettle from './kettle.js'
|
|
@@ -39,16 +35,12 @@ import deviceLight from './light.js'
|
|
|
39
35
|
import deviceOutletDouble from './outlet-double.js'
|
|
40
36
|
import deviceOutletSingle from './outlet-single.js'
|
|
41
37
|
import deviceOutletTriple from './outlet-triple.js'
|
|
42
|
-
import devicePurifierH712C from './purifier-H712C.js'
|
|
43
38
|
import devicePurifierH7120 from './purifier-H7120.js'
|
|
44
39
|
import devicePurifierH7121 from './purifier-H7121.js'
|
|
45
40
|
import devicePurifierH7122 from './purifier-H7122.js'
|
|
46
41
|
import devicePurifierH7123 from './purifier-H7123.js'
|
|
47
42
|
import devicePurifierH7124 from './purifier-H7124.js'
|
|
48
43
|
import devicePurifierH7126 from './purifier-H7126.js'
|
|
49
|
-
import devicePurifierH7127 from './purifier-H7127.js'
|
|
50
|
-
import devicePurifierH7128 from './purifier-H7128.js'
|
|
51
|
-
import devicePurifierH7129 from './purifier-H7129.js'
|
|
52
44
|
import devicePurifierSingle from './purifier-single.js'
|
|
53
45
|
import deviceSensorButton from './sensor-button.js'
|
|
54
46
|
import deviceSensorCO2 from './sensor-co2.js'
|
|
@@ -73,35 +65,38 @@ export default {
|
|
|
73
65
|
deviceDehumidifierH7151,
|
|
74
66
|
deviceDehumidifierH7152,
|
|
75
67
|
deviceDiffuserH7161,
|
|
76
|
-
deviceDiffuserH7162,
|
|
68
|
+
deviceDiffuserH7162: deviceDiffuserH7161, // identical to the H7161
|
|
69
|
+
deviceDehumidifierBasic,
|
|
77
70
|
deviceFanBasic,
|
|
78
71
|
deviceFanH1310,
|
|
79
72
|
deviceFanH1370,
|
|
80
73
|
deviceFanR1310: deviceFanH1310, // R1310 is the newer naming of the H1310
|
|
81
74
|
deviceFanH7100,
|
|
75
|
+
deviceFanH7102: deviceFanH7100, // identical to the H7100
|
|
76
|
+
deviceFanH7106: deviceFanH7100, // identical to the H7100
|
|
82
77
|
deviceFanH7101,
|
|
83
|
-
deviceFanH7102,
|
|
84
78
|
deviceFanH7105,
|
|
85
|
-
deviceFanH7106,
|
|
86
79
|
deviceFanH7107,
|
|
87
80
|
deviceFanH7111,
|
|
88
81
|
deviceHeaterSingle,
|
|
89
82
|
deviceHeater1A,
|
|
90
83
|
deviceHeater1B,
|
|
91
84
|
deviceHeater2,
|
|
92
|
-
|
|
85
|
+
deviceHumidifierBasic,
|
|
93
86
|
deviceHumidifierH7140,
|
|
94
87
|
deviceHumidifierH7141,
|
|
95
88
|
deviceHumidifierH7142,
|
|
96
89
|
deviceHumidifierH7143,
|
|
97
|
-
deviceHumidifierH7145,
|
|
98
|
-
deviceHumidifierH7147,
|
|
99
|
-
deviceHumidifierH7148,
|
|
100
|
-
deviceHumidifierH7149,
|
|
90
|
+
deviceHumidifierH7145: deviceHumidifierH7143, // identical to the H7143
|
|
91
|
+
deviceHumidifierH7147: deviceHumidifierH7143, // identical to the H7143
|
|
92
|
+
deviceHumidifierH7148: deviceHumidifierH7143, // identical to the H7143
|
|
93
|
+
deviceHumidifierH7149: deviceHumidifierH7143, // identical to the H7143
|
|
94
|
+
deviceHumidifierH714E: deviceHumidifierH7143, // identical to the H7143
|
|
101
95
|
deviceHumidifierH7160,
|
|
96
|
+
deviceIceMakerBasic,
|
|
102
97
|
deviceIceMakerH7172,
|
|
103
|
-
deviceIceMakerH717D,
|
|
104
|
-
deviceIceMakerH8120,
|
|
98
|
+
deviceIceMakerH717D: deviceIceMakerH7172, // identical to the H7172
|
|
99
|
+
deviceIceMakerH8120: deviceIceMakerH7172, // identical to the H7172
|
|
105
100
|
deviceIceMakerH8121,
|
|
106
101
|
deviceIceMakerH8122,
|
|
107
102
|
deviceKettle,
|
|
@@ -110,16 +105,17 @@ export default {
|
|
|
110
105
|
deviceOutletDouble,
|
|
111
106
|
deviceOutletSingle,
|
|
112
107
|
deviceOutletTriple,
|
|
113
|
-
|
|
108
|
+
devicePurifierBasic,
|
|
114
109
|
devicePurifierH7120,
|
|
115
110
|
devicePurifierH7121,
|
|
116
111
|
devicePurifierH7122,
|
|
117
112
|
devicePurifierH7123,
|
|
118
113
|
devicePurifierH7124,
|
|
119
114
|
devicePurifierH7126,
|
|
120
|
-
devicePurifierH7127,
|
|
121
|
-
devicePurifierH7128,
|
|
122
|
-
devicePurifierH7129,
|
|
115
|
+
devicePurifierH7127: devicePurifierH7126, // identical to the H7126
|
|
116
|
+
devicePurifierH7128: devicePurifierH7126, // identical to the H7126
|
|
117
|
+
devicePurifierH7129: devicePurifierH7126, // identical to the H7126
|
|
118
|
+
devicePurifierH712C: devicePurifierH7126, // identical to the H7126
|
|
123
119
|
devicePurifierSingle,
|
|
124
120
|
deviceSensorButton,
|
|
125
121
|
deviceSensorCO2,
|
package/lib/platform.js
CHANGED
|
@@ -1265,16 +1265,16 @@ export default class {
|
|
|
1265
1265
|
} else if (platformConsts.models.humidifier.includes(device.model)) {
|
|
1266
1266
|
// Device is a humidifier
|
|
1267
1267
|
doAWSPolling = true
|
|
1268
|
-
devInstance = deviceTypes[`deviceHumidifier${device.model}`]
|
|
1268
|
+
devInstance = deviceTypes[`deviceHumidifier${device.model}`] || deviceTypes.deviceHumidifierBasic
|
|
1269
1269
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1270
1270
|
} else if (platformConsts.models.dehumidifier.includes(device.model)) {
|
|
1271
1271
|
// Device is a dehumidifier
|
|
1272
|
-
devInstance = deviceTypes[`deviceDehumidifier${device.model}`]
|
|
1272
|
+
devInstance = deviceTypes[`deviceDehumidifier${device.model}`] || deviceTypes.deviceDehumidifierBasic
|
|
1273
1273
|
doAWSPolling = true
|
|
1274
1274
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1275
1275
|
} else if (platformConsts.models.purifier.includes(device.model)) {
|
|
1276
1276
|
// Device is a purifier
|
|
1277
|
-
devInstance = deviceTypes[`devicePurifier${device.model}`]
|
|
1277
|
+
devInstance = deviceTypes[`devicePurifier${device.model}`] || deviceTypes.devicePurifierBasic
|
|
1278
1278
|
doAWSPolling = true
|
|
1279
1279
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1280
1280
|
} else if (platformConsts.models.diffuser.includes(device.model)) {
|
|
@@ -1296,7 +1296,7 @@ export default class {
|
|
|
1296
1296
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1297
1297
|
} else if (platformConsts.models.iceMaker.includes(device.model)) {
|
|
1298
1298
|
// Device is an ice maker
|
|
1299
|
-
devInstance = deviceTypes[`deviceIceMaker${device.model}`]
|
|
1299
|
+
devInstance = deviceTypes[`deviceIceMaker${device.model}`] || deviceTypes.deviceIceMakerBasic
|
|
1300
1300
|
accessory = devicesInHB.get(uuid) || this.addAccessory(device)
|
|
1301
1301
|
} else if (platformConsts.models.template.includes(device.model)) {
|
|
1302
1302
|
// Device is a work-in-progress
|
package/lib/utils/constants.js
CHANGED
|
@@ -664,10 +664,10 @@ export default {
|
|
|
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'],
|