@homebridge-plugins/homebridge-govee 11.32.2-beta.11 → 11.32.2-beta.12

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
@@ -23,6 +23,8 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
23
23
  - fix: stop shipping the test files to npm, since they are only needed in the repository
24
24
  - fix: refuse to send a bluetooth command too long to fit in a packet, rather than sending a longer one the device quietly ignores
25
25
  - test: record what every supported model turns into, so a change can be shown to have altered nothing
26
+ - feat: work out what an appliance can do from what the device itself reports, so a model nobody has written down still gets the right controls
27
+ - feat: offer oscillation and the child lock on any appliance that reports having them, driven through govee's api rather than needing that model's command codes
26
28
 
27
29
  ## v11.32.1 (2026-08-02)
28
30
 
@@ -1,3 +1,4 @@
1
+ import { describeSources, resolveControls } from '../utils/capabilities.js'
1
2
  import { base64ToHex, getTwoItemPosition, hexToTwoItems, parseError } from '../utils/functions.js'
2
3
  import platformLang from '../utils/lang-en.js'
3
4
  import { logUnknownData, reportUnsupportedControl } from '../utils/report-unknown.js'
@@ -31,13 +32,26 @@ class ApplianceBasic {
31
32
  this.accessory = accessory
32
33
  this.config = config
33
34
 
35
+ // Work out what this particular device can do, rather than what its model
36
+ // generally can - for a model nobody has written down, what the device
37
+ // itself reported is all there is to go on
38
+ const resolved = resolveControls({
39
+ model: accessory.context.gvModel,
40
+ family: config.family,
41
+ firmware: accessory.context.firmware,
42
+ openApiCapabilities: accessory.context.openApiCapabilities,
43
+ deviceConf: platform.deviceConf?.[accessory.context.gvDeviceId],
44
+ })
45
+ this.controls = resolved.controls
46
+ this.cacheToggles = {}
47
+ this.discoveredControls = []
48
+
34
49
  // Some appliances can have a richer tile, but only if the device reports
35
50
  // the reading that tile needs - HomeKit's humidifier, for example, has to
36
51
  // 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]
52
+ // Where the device says it has that sensor, use the better tile; otherwise
53
+ // fall back to the plain one, which needs nothing extra.
54
+ const hasSensor = config.richControl && this.controls[config.richControl]
41
55
  const serviceName = (config.richService && hasSensor) ? config.richService : config.service
42
56
  this.usingRichService = serviceName === config.richService
43
57
 
@@ -94,10 +108,78 @@ class ApplianceBasic {
94
108
  this.cacheVariable = this.service.getCharacteristic(this.variableChar).value
95
109
  }
96
110
 
111
+ // A control the device told us about can be driven through Govee's own api
112
+ // by name, without knowing this model's command bytes. So unlike the
113
+ // settings above, these work rather than asking to be reported.
114
+ this.addDiscoveredToggle('swing', 'SwingMode', 'oscillationToggle')
115
+ this.addDiscoveredToggle('lock', 'LockPhysicalControls', 'lockToggle')
116
+
97
117
  // Output the customised options to the log
98
118
  const opts = JSON.stringify({})
99
119
  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`)
120
+ this.accessory.logDebug(`controls: ${describeSources(resolved.sources)}`)
121
+
122
+ const working = ['on and off', ...this.discoveredControls]
123
+ this.accessory.log(`${working.join(', ')} ${working.length > 1 ? 'are' : 'is'} supported for this model, the ${config.variableLabel || 'other settings'} are not known yet`)
124
+ }
125
+
126
+ /**
127
+ * Offer a control the device said it has, driven through Govee's api by the
128
+ * name the device gave it.
129
+ *
130
+ * This is the one place a model nobody has tested gets something that really
131
+ * works beyond power: the api takes the capability name, so the command bytes
132
+ * for this model never have to be known.
133
+ */
134
+ addDiscoveredToggle(control, characteristic, instance) {
135
+ const char = this.hapChar[characteristic]
136
+
137
+ // The device has to have named this capability itself. Knowing the model
138
+ // usually has a turning head is not enough - the api is driven by the name
139
+ // the device reported, so without it there is nothing to send, and the
140
+ // switch would fail every time it was touched.
141
+ const reported = !!this.accessory.context.openApiCapabilities?.[instance]
142
+
143
+ if (!reported || !this.controls[control] || !char) {
144
+ if (this.service.testCharacteristic?.(char)) {
145
+ this.service.removeCharacteristic(this.service.getCharacteristic(char))
146
+ }
147
+ return
148
+ }
149
+
150
+ this.service
151
+ .getCharacteristic(char)
152
+ .onSet(async value => this.internalToggleUpdate(control, char, instance, value))
153
+ this.cacheToggles[control] = this.service.getCharacteristic(char).value
154
+
155
+ this.discoveredControls.push(control === 'swing' ? 'oscillation' : 'the child lock')
156
+ }
157
+
158
+ async internalToggleUpdate(control, characteristic, instance, value) {
159
+ const previous = this.cacheToggles[control]
160
+ try {
161
+ if (previous === value) {
162
+ return
163
+ }
164
+
165
+ await this.platform.sendDeviceUpdate(this.accessory, {
166
+ cmd: 'openApi',
167
+ openApi: {
168
+ instance,
169
+ capabilityType: 'devices.capabilities.toggle',
170
+ value: value ? 1 : 0,
171
+ },
172
+ })
173
+
174
+ this.cacheToggles[control] = value
175
+ this.accessory.log(`current ${control} [${value ? 'on' : 'off'}]`)
176
+ } catch (err) {
177
+ this.accessory.logWarn(`${platformLang.devNotUpdated} ${parseError(err)}`)
178
+ setTimeout(() => {
179
+ this.service.updateCharacteristic(characteristic, previous)
180
+ }, 2000)
181
+ throw new this.hapErr(-70402)
182
+ }
101
183
  }
102
184
 
103
185
  async internalStateUpdate(value) {
@@ -229,6 +311,7 @@ export function makeBasicAppliance(config) {
229
311
 
230
312
  // Fan-shaped appliances: a speed slider that is not worked out yet
231
313
  export const deviceFanBasic = makeBasicAppliance({
314
+ family: 'fan',
232
315
  service: 'Fanv2',
233
316
  variableChar: 'RotationSpeed',
234
317
  variableLabel: 'fan speed',
@@ -236,6 +319,7 @@ export const deviceFanBasic = makeBasicAppliance({
236
319
 
237
320
  // Ice makers reuse the fan tile, with the slider standing in for ice size
238
321
  export const deviceIceMakerBasic = makeBasicAppliance({
322
+ family: 'iceMaker',
239
323
  service: 'Fanv2',
240
324
  variableChar: 'RotationSpeed',
241
325
  variableLabel: 'ice size',
@@ -245,11 +329,12 @@ export const deviceIceMakerBasic = makeBasicAppliance({
245
329
  // has to show a current humidity and many Govee humidifiers never report one.
246
330
  // A model that does report it gets the proper humidifier tile instead.
247
331
  export const deviceHumidifierBasic = makeBasicAppliance({
332
+ family: 'humidifier',
248
333
  service: 'Fan',
249
334
  variableChar: 'RotationSpeed',
250
335
  variableLabel: 'mist level',
251
336
  richService: 'HumidifierDehumidifier',
252
- richCapability: 'sensorHumidity',
337
+ richControl: 'humiditySensor',
253
338
  richVariableChar: 'RelativeHumidityHumidifierThreshold',
254
339
  richReadingChar: 'CurrentRelativeHumidity',
255
340
  currentStateChar: 'CurrentHumidifierDehumidifierState',
@@ -258,11 +343,12 @@ export const deviceHumidifierBasic = makeBasicAppliance({
258
343
  })
259
344
 
260
345
  export const deviceDehumidifierBasic = makeBasicAppliance({
346
+ family: 'dehumidifier',
261
347
  service: 'Fan',
262
348
  variableChar: 'RotationSpeed',
263
349
  variableLabel: 'fan speed',
264
350
  richService: 'HumidifierDehumidifier',
265
- richCapability: 'sensorHumidity',
351
+ richControl: 'humiditySensor',
266
352
  richVariableChar: 'RelativeHumidityDehumidifierThreshold',
267
353
  richReadingChar: 'CurrentRelativeHumidity',
268
354
  currentStateChar: 'CurrentHumidifierDehumidifierState',
@@ -271,6 +357,7 @@ export const deviceDehumidifierBasic = makeBasicAppliance({
271
357
  })
272
358
 
273
359
  export const devicePurifierBasic = makeBasicAppliance({
360
+ family: 'purifier',
274
361
  service: 'AirPurifier',
275
362
  variableChar: 'RotationSpeed',
276
363
  variableLabel: 'fan speed',
@@ -1,4 +1,4 @@
1
- import { getHeaterFeatures } from '../utils/appliance-capabilities.js'
1
+ import { describeSources, resolveControls } from '../utils/capabilities.js'
2
2
  import {
3
3
  base64ToHex,
4
4
  farToCen,
@@ -94,7 +94,15 @@ export default class {
94
94
 
95
95
  // Only offer the controls this model actually has, so a heater without a
96
96
  // turning head does not get an oscillation switch that does nothing
97
- this.features = getHeaterFeatures(this.accessory.context.gvModel)
97
+ const resolved = resolveControls({
98
+ model: this.accessory.context.gvModel,
99
+ family: 'heater1',
100
+ firmware: this.accessory.context.firmware,
101
+ openApiCapabilities: this.accessory.context.openApiCapabilities,
102
+ deviceConf: platform.deviceConf?.[this.accessory.context.gvDeviceId],
103
+ })
104
+ this.features = resolved.controls
105
+ this.accessory.logDebug(`controls: ${describeSources(resolved.sources)}`)
98
106
 
99
107
  // Add the set handler to the heater swing mode characteristic (for oscillation)
100
108
  if (this.features.swing) {
@@ -0,0 +1,220 @@
1
+ /**
2
+ * What a device can do, worked out from several sources rather than one table.
3
+ *
4
+ * Writing out all six hundred models by hand is a job nobody finishes, and it
5
+ * throws away the two things we already have: Govee's own API says what each
6
+ * device can do, and some behaviour depends on firmware rather than model. So
7
+ * instead of a table this asks a series of layers and takes the first answer:
8
+ *
9
+ * 1. user config an explicit setting always wins
10
+ * 2. firmware this model, at or above this firmware version
11
+ * 3. model written down because someone worked it out
12
+ * 4. discovered what this particular device reported it can do
13
+ * 5. family what this kind of appliance usually has
14
+ *
15
+ * Most models never need an entry of their own. They are answered by what the
16
+ * device reported plus the family defaults, which is why this scales to models
17
+ * nobody has written down.
18
+ *
19
+ * ⚠️ Family defaults are deliberately generous. A control this plugin has
20
+ * offered for years may be in use by someone, and taking it away to tidy up
21
+ * would break a working setup. Only a model entry - written after someone
22
+ * confirmed it - is allowed to say a device does not have something.
23
+ */
24
+
25
+ import { compareVersions } from './device-capabilities.js'
26
+
27
+ /**
28
+ * What each kind of appliance usually offers.
29
+ *
30
+ * These are the fallback, so they describe the group rather than any one model.
31
+ * Where a group has historically been given a control, it stays here even if
32
+ * some members turn out not to have it - see the warning above.
33
+ */
34
+ const familyControls = {
35
+ heater1: { swing: true, lock: true, light: false },
36
+ heater2: { swing: true, lock: true, light: true },
37
+ fan: { swing: true, lock: false, light: false },
38
+ humidifier: { swing: false, lock: false, light: false },
39
+ dehumidifier: { swing: false, lock: false, light: false },
40
+ purifier: { swing: false, lock: false, light: false },
41
+ iceMaker: { swing: false, lock: false, light: false },
42
+ diffuser: { swing: false, lock: false, light: false },
43
+ }
44
+
45
+ const emptyControls = {
46
+ swing: false,
47
+ lock: false,
48
+ light: false,
49
+ humiditySensor: false,
50
+ temperatureSensor: false,
51
+ modes: false,
52
+ }
53
+
54
+ /**
55
+ * Controls worked out for a particular model.
56
+ *
57
+ * Only add a model here once its controls are actually known. An entry is
58
+ * trusted over what the device reports, and it is the only thing allowed to
59
+ * take a control away.
60
+ */
61
+ const modelControls = {
62
+ // Heaters. These match what Govee's own app offers for each model.
63
+ // Added new, so there is nobody whose setup could be broken by them
64
+ H7137: { swing: false, lock: false, light: false },
65
+ H713E: { swing: false, lock: false, light: false },
66
+
67
+ // Diffusers are power only, which is what the plugin already offers
68
+ H7161: { swing: false, lock: false, light: false },
69
+ H7162: { swing: false, lock: false, light: false },
70
+ }
71
+
72
+ /**
73
+ * Controls a model is believed NOT to have, which are still being offered.
74
+ *
75
+ * These are recorded rather than applied. Each has been offered for a long
76
+ * time, so an owner may be relying on it, and taking it away to match a
77
+ * capability list would break a working setup for the sake of tidiness. The
78
+ * evidence is written down here so nobody has to work it out twice, and so
79
+ * there is a list to check the next time an owner of one of these gets in
80
+ * touch.
81
+ *
82
+ * Move an entry into `modelControls` once an owner confirms the control really
83
+ * does nothing.
84
+ */
85
+ const unconfirmedMissing = {
86
+ H713A: { swing: false },
87
+ H713B: { swing: false },
88
+ H713C: { swing: false },
89
+ H7135: { swing: false, light: false },
90
+ }
91
+
92
+ /**
93
+ * Controls that only appear at or above a firmware version. Same shape as the
94
+ * protocol overrides next door: a list per model, oldest rule first.
95
+ */
96
+ const firmwareControls = {}
97
+
98
+ /**
99
+ * What Govee's API calls a capability, and which control it means to us.
100
+ *
101
+ * These names come back per device, so they say what this particular unit can
102
+ * do rather than what its model generally can. That makes them the only source
103
+ * here that works for a model nobody has written down.
104
+ */
105
+ const discoveryMap = {
106
+ oscillationToggle: 'swing',
107
+ lockToggle: 'lock',
108
+ nightlightToggle: 'light',
109
+ mainLightToggle: 'light',
110
+ backgroundLightToggle: 'light',
111
+ sensorHumidity: 'humiditySensor',
112
+ sensorTemperature: 'temperatureSensor',
113
+ workMode: 'modes',
114
+ }
115
+
116
+ /**
117
+ * Turn the capability list a device reported into controls.
118
+ *
119
+ * Only ever adds. A capability the device did not mention is left undecided so
120
+ * a later layer can answer, because the API not listing something is not proof
121
+ * the device cannot do it.
122
+ */
123
+ function fromDiscovery(openApiCapabilities) {
124
+ const discovered = {}
125
+ Object.keys(openApiCapabilities || {}).forEach((instance) => {
126
+ const control = discoveryMap[instance]
127
+ if (control) {
128
+ discovered[control] = true
129
+ }
130
+ })
131
+ return discovered
132
+ }
133
+
134
+ /**
135
+ * Config settings that name a control directly, so an owner can turn something
136
+ * off that their device does not really have without waiting for a release.
137
+ */
138
+ function fromConfig(deviceConf) {
139
+ const chosen = {}
140
+ if (deviceConf?.hideLight === true) {
141
+ chosen.light = false
142
+ }
143
+ if (deviceConf?.hideSwing === true) {
144
+ chosen.swing = false
145
+ }
146
+ return chosen
147
+ }
148
+
149
+ function firmwareLayer(model, firmware) {
150
+ if (!firmware) {
151
+ return {}
152
+ }
153
+ const applied = {}
154
+ const rules = [...(firmwareControls[model] || [])]
155
+ .sort((a, b) => compareVersions(a.minVersion, b.minVersion))
156
+ rules.forEach((rule) => {
157
+ if (compareVersions(firmware, rule.minVersion) >= 0) {
158
+ Object.assign(applied, rule.controls)
159
+ }
160
+ })
161
+ return applied
162
+ }
163
+
164
+ /**
165
+ * Work out what a device can do.
166
+ *
167
+ * @param {object} context describing the device
168
+ * @param {string} context.model the govee model, eg 'H7130'
169
+ * @param {string} [context.family] which set of defaults to fall back on
170
+ * @param {string} [context.firmware] the firmware version the device reported
171
+ * @param {object} [context.openApiCapabilities] what the api said it can do
172
+ * @param {object} [context.deviceConf] this device's settings
173
+ * @returns {{ controls: object, sources: object }} the controls, and which
174
+ * layer decided each one - the second is for the log, so an unexpected answer
175
+ * can be traced without guessing
176
+ */
177
+ export function resolveControls(context = {}) {
178
+ const {
179
+ model,
180
+ family,
181
+ firmware,
182
+ openApiCapabilities,
183
+ deviceConf,
184
+ } = context
185
+
186
+ // Lowest priority first, so a later layer overwrites an earlier one
187
+ const layers = [
188
+ ['family', familyControls[family] || {}],
189
+ ['discovered', fromDiscovery(openApiCapabilities)],
190
+ ['model', modelControls[model] || {}],
191
+ ['firmware', firmwareLayer(model, firmware)],
192
+ ['config', fromConfig(deviceConf)],
193
+ ]
194
+
195
+ const controls = { ...emptyControls }
196
+ const sources = {}
197
+
198
+ layers.forEach(([name, values]) => {
199
+ Object.entries(values).forEach(([control, value]) => {
200
+ controls[control] = value
201
+ sources[control] = name
202
+ })
203
+ })
204
+
205
+ return { controls, sources }
206
+ }
207
+
208
+ /**
209
+ * A one-line summary of where each answer came from, for the debug log. Reading
210
+ * "swing from family" tells you nobody has confirmed it, which is a different
211
+ * situation from "swing from model".
212
+ */
213
+ export function describeSources(sources) {
214
+ return Object.entries(sources)
215
+ .sort(([a], [b]) => a.localeCompare(b))
216
+ .map(([control, source]) => `${control} from ${source}`)
217
+ .join(', ') || 'nothing known'
218
+ }
219
+
220
+ export { familyControls, modelControls, unconfirmedMissing }
@@ -80,7 +80,7 @@ const firmwareOverrides = {
80
80
  * newer than `b`, negative if older, and zero if they match. Missing parts
81
81
  * count as zero, so "1.03" and "1.03.00" are equal.
82
82
  */
83
- function compareVersions(a, b) {
83
+ export function compareVersions(a, b) {
84
84
  const aParts = String(a).split('.')
85
85
  const bParts = String(b).split('.')
86
86
  for (let i = 0; i < Math.max(aParts.length, bParts.length); i += 1) {
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.11",
5
+ "version": "11.32.2-beta.12",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",
@@ -1,45 +0,0 @@
1
- /**
2
- * What each appliance model can actually do.
3
- *
4
- * Appliance handlers are shared between models, which historically meant every
5
- * model in a group was given every control the group could offer - so a heater
6
- * without a turning head still got an oscillation switch that did nothing. This
7
- * map lets a shared handler offer only the controls a model really has, without
8
- * needing a separate file per model.
9
- *
10
- * A model that is not listed keeps the handler's full set of controls, so
11
- * adding an entry is opt-in and nothing changes for a model until it is
12
- * described here.
13
- */
14
-
15
- /**
16
- * Heaters.
17
- *
18
- * ⚠️ Several models already in the plugin are believed not to have oscillation
19
- * (the H713A, H713B, H713C and H7135), but they have been offered it for a long
20
- * time. They are deliberately NOT restricted here: if any owner is using that
21
- * control today, taking it away would break a working setup for the sake of
22
- * tidiness. Restrict them only once an owner has confirmed the control does
23
- * nothing. New models are added with their real capabilities from the start,
24
- * where there is nobody to break.
25
- */
26
- const heaterFeatures = {
27
- // Power and mode only - no turning head, no night light
28
- H7137: { swing: false, lock: false },
29
- H713E: { swing: false, lock: false },
30
- }
31
-
32
- const heaterDefaults = {
33
- swing: true,
34
- lock: true,
35
- }
36
-
37
- /**
38
- * Get the controls a heater model should offer.
39
- *
40
- * @param {string} model the govee model, eg 'H7130'
41
- * @returns {{ swing: boolean, lock: boolean }} the controls to offer
42
- */
43
- export function getHeaterFeatures(model) {
44
- return { ...heaterDefaults, ...(heaterFeatures[model] || {}) }
45
- }