@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 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
+ })
@@ -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,
@@ -20,7 +20,14 @@ export default class {
20
20
  }
21
21
 
22
22
  externalUpdate(params) {
23
- this.accessory.logWarn(JSON.stringify(params))
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
- devInstance = deviceTypes[`deviceFan${device.model}`]
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
@@ -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
- 'H5085', // https://github.com/homebridge-plugins/homebridge-govee/issues/951
683
- 'H5111', // https://github.com/homebridge-plugins/homebridge-govee/issues/1117
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
@@ -2,7 +2,7 @@
2
2
  "name": "@homebridge-plugins/homebridge-govee",
3
3
  "alias": "Govee",
4
4
  "type": "module",
5
- "version": "11.32.2-beta.4",
5
+ "version": "11.32.2-beta.6",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",