@homebridge-plugins/homebridge-govee 11.32.2-beta.17 → 11.32.2-beta.18

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
@@ -36,6 +36,7 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
36
36
  - refactor: choose a device handler from a table, so adding a device type no longer means editing the platform
37
37
  - refactor: move the settings parsing into its own file, away from the code that talks to devices
38
38
  - refactor: move the work of describing a device to its handler into its own file
39
+ - refactor: move the rules for combining govee's device lists, and for letting go of a device that has gone, into their own file with tests
39
40
 
40
41
  ## v11.32.1 (2026-08-02)
41
42
 
package/lib/platform.js CHANGED
@@ -18,6 +18,7 @@ import { applyUserConfig } from './utils/config-parser.js'
18
18
  import platformConsts from './utils/constants.js'
19
19
  import platformChars from './utils/custom-chars.js'
20
20
  import { applyDeviceContext } from './utils/device-context.js'
21
+ import { cloudListsWereFetched, planDeviceSetup, planRedundantAccessories } from './utils/device-merge.js'
21
22
  import { resolveDeviceType } from './utils/dispatch.js'
22
23
  import eveChars from './utils/eve-chars.js'
23
24
  import {
@@ -342,192 +343,45 @@ export default class {
342
343
  throwOnTimeout: true,
343
344
  })
344
345
 
345
- // Initialise the devices
346
- let bleSyncNeeded = false
347
- let httpSyncNeeded = false
348
- let lanDevicesWereInitialised = false
349
- let httpDevicesWereInitialised = false
350
- let openApiDevicesWereInitialised = false
351
-
352
- if (httpDevices && httpDevices.length > 0) {
353
- // We have some devices from HTTP client
354
- httpDevices.forEach((httpDevice) => {
355
- // Format device id
356
- if (!httpDevice.device.includes(':')) {
357
- // Eg converts abcd1234abcd1234 to AB:CD:12:34:AB:CD:12:34
358
- // For sensors with an add-on sensor like H5178
359
- // Eg converts abcd1234abcd1234_1 to AB:CD:12:34:AB:CD:12:34_1
360
- httpDevice.device = httpDevice.device.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase()
361
- }
362
-
363
- // Check it's not a user-ignored device
364
- if (this.ignoredDevices.includes(httpDevice.device)) {
365
- return
366
- }
367
-
368
- // Skip Govee app device groups as these are not real devices (#1309)
369
- if (['BaseGroup', 'SameModeGroup'].includes(httpDevice.sku)) {
370
- this.log.debug('[%s] %s.', httpDevice.deviceName, platformLang.devIsGroup)
371
- return
372
- }
373
-
374
- // Check it's not a matter-ignored device, if the config has been set
375
- if (platformConsts.matterModels.includes(httpDevice.sku) && this.config.ignoreMatter) {
376
- return
377
- }
378
-
379
- // Sets the flag to see if we need to set up the BLE/HTTP syncs
380
- if (platformConsts.models.sensorLeak.includes(httpDevice.sku)) {
381
- httpSyncNeeded = true
382
- }
383
- if (platformConsts.models.sensorThermo.includes(httpDevice.sku)) {
384
- bleSyncNeeded = true
385
- httpSyncNeeded = true
386
- }
387
-
388
- // Find any matching device from the LAN client
389
- const lanDevice = lanDevices.find(el => el.device === httpDevice.device)
390
-
391
- // Find any matching device from the OpenAPI client to merge capabilities
392
- const matchingOpenApiDevice = openApiDevices.find(el => el.device === httpDevice.device)
393
- const openApiMerge = matchingOpenApiDevice
394
- ? {
395
- openApiInfo: matchingOpenApiDevice.openApiInfo,
396
- properties: matchingOpenApiDevice.properties,
397
- supportCmds: matchingOpenApiDevice.supportCmds,
398
- }
399
- : {}
400
-
401
- if (lanDevice) {
402
- // Device exists in LAN data so add the http info to the object and initialise
403
- this.initialiseDevice({
404
- ...lanDevice,
405
- httpInfo: httpDevice,
406
- model: httpDevice.sku,
407
- deviceName: httpDevice.deviceName,
408
- isLanDevice: true,
409
- ...openApiMerge,
410
- })
411
- lanDevicesWereInitialised = true
412
- lanDevice.initialised = true
413
- } else {
414
- // Device doesn't exist in LAN data, but try to initialise as could be other device type
415
- this.initialiseDevice({
416
- device: httpDevice.device,
417
- deviceName: httpDevice.deviceName,
418
- model: httpDevice.sku,
419
- httpInfo: httpDevice,
420
- ...openApiMerge,
421
- })
422
- }
423
- httpDevicesWereInitialised = true
424
- })
425
- }
426
-
427
- if (openApiDevices && openApiDevices.length > 0) {
428
- openApiDevices.forEach((openApiDevice) => {
429
- // Skip Govee app device groups as these are not real devices (#1309)
430
- if (['BaseGroup', 'SameModeGroup'].includes(openApiDevice.model)) {
431
- this.log.debug('[%s] %s.', openApiDevice.deviceName, platformLang.devIsGroup)
432
- return
433
- }
434
-
435
- if (this.ignoredDevices.includes(openApiDevice.device)) {
436
- return
437
- }
438
-
439
- if (platformConsts.matterModels.includes(openApiDevice.model) && this.config.ignoreMatter) {
440
- return
441
- }
442
-
443
- if (httpDevices.some(httpDevice => httpDevice.device === openApiDevice.device)) {
444
- return
445
- }
446
-
447
- const lanDevice = lanDevices.find(el => el.device === openApiDevice.device)
448
-
449
- if (lanDevice) {
450
- this.initialiseDevice({
451
- ...lanDevice,
452
- deviceName: openApiDevice.deviceName,
453
- model: openApiDevice.model,
454
- openApiInfo: openApiDevice.openApiInfo,
455
- properties: openApiDevice.properties,
456
- supportCmds: openApiDevice.supportCmds,
457
- isLanDevice: true,
458
- })
459
- lanDevicesWereInitialised = true
460
- lanDevice.initialised = true
461
- } else {
462
- this.initialiseDevice(openApiDevice)
463
- }
464
-
465
- openApiDevicesWereInitialised = true
466
- })
467
- }
346
+ // Work out what to set up. Govee describes the same device through up to
347
+ // three lists, and how they combine is decided away from here so it can
348
+ // be tested rather than hoped about
349
+ const plan = planDeviceSetup({
350
+ httpDevices,
351
+ lanDevices,
352
+ openApiDevices,
353
+ ignoredDevices: this.ignoredDevices,
354
+ ignoreMatter: this.config.ignoreMatter,
355
+ deviceConf: this.deviceConf,
356
+ })
468
357
 
469
- // Some LAN devices may exist outside the HTTP client
470
- const pendingLANDevices = lanDevices.filter(el => !el.initialised)
471
- if (pendingLANDevices.length > 0) {
472
- // No devices from HTTP client, but LAN devices exist
473
- pendingLANDevices.forEach((lanDevice) => {
474
- // Check it's not a user-ignored device
475
- if (this.ignoredDevices.includes(lanDevice.device)) {
476
- return
477
- }
358
+ plan.skippedGroups.forEach(name => this.log.debug('[%s] %s.', name, platformLang.devIsGroup))
359
+ plan.devices.forEach(device => this.initialiseDevice(device))
478
360
 
479
- // Check it's not a matter-ignored device, if the config has been set, since a
480
- // device skipped by the http/openapi loops for this reason still has an
481
- // unmatched lan record, which would otherwise be added here and then removed
482
- // again by the cleanup below on every restart (#1315)
483
- if (this.config.ignoreMatter && platformConsts.matterModels.includes(lanDevice.sku)) {
484
- return
485
- }
361
+ const {
362
+ bleSyncNeeded,
363
+ httpSyncNeeded,
364
+ httpDevicesWereInitialised,
365
+ openApiDevicesWereInitialised,
366
+ lanDevicesWereInitialised,
367
+ } = plan
486
368
 
487
- // Initialise the device into Homebridge
488
- // Since LAN does not provide a name, we will use the configured label or device id
489
- this.initialiseDevice({
490
- device: lanDevice.device,
491
- deviceName: this.deviceConf?.[lanDevice.device]?.label || lanDevice.device.replaceAll(':', ''),
492
- model: lanDevice.sku || 'HXXXX', // In case the model is not provided
493
- isLanDevice: true,
494
- isLanOnly: true,
495
- })
496
- lanDevicesWereInitialised = true
497
- })
498
- }
499
-
500
- if (!lanDevicesWereInitialised && !httpDevicesWereInitialised && !openApiDevicesWereInitialised) {
501
- // No devices either from HTTP client nor LAN client
369
+ if (!plan.anyInitialised) {
502
370
  throw new Error(platformLang.noDevs)
503
371
  }
504
372
 
505
- // Check for redundant Homebridge accessories
506
- // Only treat 'not found in Govee' as a reason to remove an accessory when the
507
- // configured cloud clients actually returned their device lists this session,
508
- // otherwise a failed fetch would remove the accessory and destroy any HomeKit
509
- // scenes and automations it is part of (#1264)
510
- const cloudListsFetched = (!this.config.username || !!this.httpClient)
511
- && (!!this.config.openApiDisable || !this.config.apiKey || !!this.openApiClient)
512
- devicesInHB.forEach((accessory) => {
513
- // Remove the accessory if it is now being ignored, or if it is a matter model
514
- // and the user has enabled ignoreMatter (otherwise the cached accessory lingers
515
- // even though the device is skipped during setup) (#1287)
516
- const isIgnored = this.ignoredDevices.includes(accessory.context.gvDeviceId)
517
- || (this.config.ignoreMatter && platformConsts.matterModels.includes(accessory.context.gvModel))
518
-
519
- // Also remove it if the device no longer exists in Govee, but never based on
520
- // an incomplete cloud list, and never for LAN-only devices which may simply
521
- // have missed a scan window (#1264)
522
- const isMissing = !httpDevices.some(el => el.device === accessory.context.gvDeviceId)
523
- && !lanDevices.some(el => el.device === accessory.context.gvDeviceId)
524
- && !openApiDevices.some(el => el.device === accessory.context.gvDeviceId)
525
- const isLanOnly = accessory.context.hasLanControl && !accessory.context.firmware
526
-
527
- if (isIgnored || (isMissing && cloudListsFetched && !isLanOnly)) {
528
- this.removeAccessory(accessory)
529
- }
530
- })
373
+ // Let go of any cached accessory that no longer belongs. Removing one
374
+ // destroys the HomeKit scenes and automations it is part of, so the rules
375
+ // for this live next to their tests
376
+ planRedundantAccessories({
377
+ accessories: [...devicesInHB.values()],
378
+ httpDevices,
379
+ lanDevices,
380
+ openApiDevices,
381
+ ignoredDevices: this.ignoredDevices,
382
+ ignoreMatter: this.config.ignoreMatter,
383
+ cloudListsFetched: cloudListsWereFetched(this.config, this),
384
+ }).forEach(accessory => this.removeAccessory(accessory))
531
385
 
532
386
  // Set up the ble client sync needed for thermo sensor devices
533
387
  if (bleSyncNeeded) {
@@ -0,0 +1,251 @@
1
+ import platformConsts from './constants.js'
2
+ import platformLang from './lang-en.js'
3
+
4
+ /**
5
+ * Works out which devices to set up, and which cached ones to let go of.
6
+ *
7
+ * Govee can describe the same device through three different lists - the
8
+ * account's own device list, the local network scan, and the public api - and
9
+ * each carries something the others do not. This decides how they combine.
10
+ *
11
+ * Nothing here talks to Homebridge. It returns a plan, and the platform carries
12
+ * it out, which is what lets the awkward cases below be tested rather than
13
+ * hoped about.
14
+ */
15
+
16
+ const DEVICE_ID_FORMAT_REGEX = /([a-z0-9]{2})(?=[a-z0-9])/gi
17
+
18
+ // The app lets people group devices together. Those groups come back looking
19
+ // like devices, and are not (#1309)
20
+ const GROUP_SKUS = ['BaseGroup', 'SameModeGroup']
21
+
22
+ function isGroup(sku) {
23
+ return GROUP_SKUS.includes(sku)
24
+ }
25
+
26
+ /**
27
+ * Govee's own list gives ids without separators, and everything else uses them.
28
+ * An add-on sensor keeps its `_1` suffix.
29
+ */
30
+ function formatDeviceId(id) {
31
+ return id.includes(':') ? id : id.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase()
32
+ }
33
+
34
+ /**
35
+ * Decide what to set up.
36
+ *
37
+ * @param {object} input the three device lists and the settings that filter them
38
+ * @param {Array} [input.httpDevices] what the account's device list returned
39
+ * @param {Array} [input.lanDevices] what the local network scan found
40
+ * @param {Array} [input.openApiDevices] what the public api returned
41
+ * @param {Array} [input.ignoredDevices] ids the owner asked to leave alone
42
+ * @param {boolean} [input.ignoreMatter] whether matter models are being skipped
43
+ * @param {object} [input.deviceConf] per-device settings, for names
44
+ * @returns {object} `devices` to hand to the platform in order, which periodic
45
+ * syncs are needed, which lists produced something, and any app groups that
46
+ * were skipped so the caller can log them
47
+ */
48
+ export function planDeviceSetup({
49
+ httpDevices = [],
50
+ lanDevices = [],
51
+ openApiDevices = [],
52
+ ignoredDevices = [],
53
+ ignoreMatter = false,
54
+ deviceConf = {},
55
+ }) {
56
+ const devices = []
57
+ const skippedGroups = []
58
+ let bleSyncNeeded = false
59
+ let httpSyncNeeded = false
60
+ // Which lists produced something, since each one decides whether its own
61
+ // periodic sync is worth starting
62
+ let httpDevicesWereInitialised = false
63
+ let openApiDevicesWereInitialised = false
64
+ let lanDevicesWereInitialised = false
65
+
66
+ const isIgnored = id => ignoredDevices.includes(id)
67
+ const isIgnoredMatter = model => ignoreMatter && platformConsts.matterModels.includes(model)
68
+
69
+ httpDevices.forEach((httpDevice) => {
70
+ // Kept in place, because the lists are compared by id further down and by
71
+ // the caller afterwards
72
+ httpDevice.device = formatDeviceId(httpDevice.device)
73
+
74
+ if (isIgnored(httpDevice.device)) {
75
+ return
76
+ }
77
+ if (isGroup(httpDevice.sku)) {
78
+ skippedGroups.push(httpDevice.deviceName)
79
+ return
80
+ }
81
+ if (isIgnoredMatter(httpDevice.sku)) {
82
+ return
83
+ }
84
+
85
+ // These two kinds of sensor are the reason the periodic syncs exist
86
+ if (platformConsts.models.sensorLeak.includes(httpDevice.sku)) {
87
+ httpSyncNeeded = true
88
+ }
89
+ if (platformConsts.models.sensorThermo.includes(httpDevice.sku)) {
90
+ bleSyncNeeded = true
91
+ httpSyncNeeded = true
92
+ }
93
+
94
+ const lanDevice = lanDevices.find(el => el.device === httpDevice.device)
95
+ const matchingOpenApi = openApiDevices.find(el => el.device === httpDevice.device)
96
+ const openApiMerge = matchingOpenApi
97
+ ? {
98
+ openApiInfo: matchingOpenApi.openApiInfo,
99
+ properties: matchingOpenApi.properties,
100
+ supportCmds: matchingOpenApi.supportCmds,
101
+ }
102
+ : {}
103
+
104
+ if (lanDevice) {
105
+ devices.push({
106
+ ...lanDevice,
107
+ httpInfo: httpDevice,
108
+ model: httpDevice.sku,
109
+ deviceName: httpDevice.deviceName,
110
+ isLanDevice: true,
111
+ ...openApiMerge,
112
+ })
113
+ lanDevicesWereInitialised = true
114
+ lanDevice.initialised = true
115
+ } else {
116
+ // Not on the local network, but it may still be reachable another way
117
+ devices.push({
118
+ device: httpDevice.device,
119
+ deviceName: httpDevice.deviceName,
120
+ model: httpDevice.sku,
121
+ httpInfo: httpDevice,
122
+ ...openApiMerge,
123
+ })
124
+ }
125
+ httpDevicesWereInitialised = true
126
+ })
127
+
128
+ openApiDevices.forEach((openApiDevice) => {
129
+ if (isGroup(openApiDevice.model)) {
130
+ skippedGroups.push(openApiDevice.deviceName)
131
+ return
132
+ }
133
+ if (isIgnored(openApiDevice.device) || isIgnoredMatter(openApiDevice.model)) {
134
+ return
135
+ }
136
+ // Already handled by the loop above
137
+ if (httpDevices.some(httpDevice => httpDevice.device === openApiDevice.device)) {
138
+ return
139
+ }
140
+
141
+ const lanDevice = lanDevices.find(el => el.device === openApiDevice.device)
142
+ if (lanDevice) {
143
+ devices.push({
144
+ ...lanDevice,
145
+ deviceName: openApiDevice.deviceName,
146
+ model: openApiDevice.model,
147
+ openApiInfo: openApiDevice.openApiInfo,
148
+ properties: openApiDevice.properties,
149
+ supportCmds: openApiDevice.supportCmds,
150
+ isLanDevice: true,
151
+ })
152
+ lanDevicesWereInitialised = true
153
+ lanDevice.initialised = true
154
+ } else {
155
+ devices.push(openApiDevice)
156
+ }
157
+ openApiDevicesWereInitialised = true
158
+ })
159
+
160
+ // Anything found on the network that neither list knew about
161
+ lanDevices.filter(el => !el.initialised).forEach((lanDevice) => {
162
+ if (isIgnored(lanDevice.device)) {
163
+ return
164
+ }
165
+ // A device the loops above skipped for this reason still has an unmatched
166
+ // network record. Without this it would be added here and removed again by
167
+ // the cleanup on every restart (#1315)
168
+ if (isIgnoredMatter(lanDevice.sku)) {
169
+ return
170
+ }
171
+
172
+ devices.push({
173
+ device: lanDevice.device,
174
+ // The network scan does not give a name, so use the owner's label
175
+ deviceName: deviceConf?.[lanDevice.device]?.label || lanDevice.device.replaceAll(':', ''),
176
+ model: lanDevice.sku || 'HXXXX',
177
+ isLanDevice: true,
178
+ isLanOnly: true,
179
+ })
180
+ lanDevicesWereInitialised = true
181
+ })
182
+
183
+ return {
184
+ devices,
185
+ bleSyncNeeded,
186
+ httpSyncNeeded,
187
+ skippedGroups,
188
+ httpDevicesWereInitialised,
189
+ openApiDevicesWereInitialised,
190
+ lanDevicesWereInitialised,
191
+ anyInitialised: httpDevicesWereInitialised || openApiDevicesWereInitialised || lanDevicesWereInitialised,
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Decide which cached accessories no longer belong.
197
+ *
198
+ * ⚠️ The careful part. Removing an accessory destroys the HomeKit scenes and
199
+ * automations it is in, and that cannot be undone from here. So "Govee did not
200
+ * mention it" only counts when the cloud lists were actually fetched this time
201
+ * - a failed request must never look like a deleted device (#1264).
202
+ *
203
+ * @param {object} input the cached accessories and what was seen this session
204
+ * @param {Array} [input.accessories] the accessories Homebridge already has
205
+ * @param {Array} [input.httpDevices] what the account's device list returned
206
+ * @param {Array} [input.lanDevices] what the local network scan found
207
+ * @param {Array} [input.openApiDevices] what the public api returned
208
+ * @param {Array} [input.ignoredDevices] ids the owner asked to leave alone
209
+ * @param {boolean} [input.ignoreMatter] whether matter models are being skipped
210
+ * @param {boolean} [input.cloudListsFetched] whether the cloud lists can be
211
+ * trusted to be complete this session
212
+ * @returns {Array} the accessories to remove
213
+ */
214
+ export function planRedundantAccessories({
215
+ accessories = [],
216
+ httpDevices = [],
217
+ lanDevices = [],
218
+ openApiDevices = [],
219
+ ignoredDevices = [],
220
+ ignoreMatter = false,
221
+ cloudListsFetched = false,
222
+ }) {
223
+ return accessories.filter((accessory) => {
224
+ const id = accessory.context.gvDeviceId
225
+
226
+ // Asked to be ignored, or a matter model while matter models are ignored -
227
+ // otherwise the cached tile lingers even though setup skipped it (#1287)
228
+ const isIgnored = ignoredDevices.includes(id)
229
+ || (ignoreMatter && platformConsts.matterModels.includes(accessory.context.gvModel))
230
+
231
+ const isMissing = !httpDevices.some(el => el.device === id)
232
+ && !lanDevices.some(el => el.device === id)
233
+ && !openApiDevices.some(el => el.device === id)
234
+
235
+ // A device only ever seen on the network may simply have missed a scan
236
+ const isLanOnly = accessory.context.hasLanControl && !accessory.context.firmware
237
+
238
+ return isIgnored || (isMissing && cloudListsFetched && !isLanOnly)
239
+ })
240
+ }
241
+
242
+ /**
243
+ * Whether the cloud lists can be trusted to be complete this session. A client
244
+ * that was configured but failed to connect means the answer is no.
245
+ */
246
+ export function cloudListsWereFetched(config, clients) {
247
+ return (!config.username || !!clients.httpClient)
248
+ && (!!config.openApiDisable || !config.apiKey || !!clients.openApiClient)
249
+ }
250
+
251
+ export { formatDeviceId, platformLang }
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.17",
5
+ "version": "11.32.2-beta.18",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",