@homebridge-plugins/homebridge-govee 11.32.2-beta.16 → 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
@@ -35,6 +35,8 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
35
35
  - feat: give a diffuser model that has not been worked out yet the same starting handler the other appliances have, so it works for on and off
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
+ - 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
38
40
 
39
41
  ## v11.32.1 (2026-08-02)
40
42
 
package/lib/platform.js CHANGED
@@ -17,7 +17,8 @@ import { buildCommand } from './utils/command-builder.js'
17
17
  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
- import { getDeviceCapabilities } from './utils/device-capabilities.js'
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) {
@@ -860,7 +714,6 @@ export default class {
860
714
  }
861
715
 
862
716
  const devInstance = route.handler
863
- const doAWSPolling = route.awsPolling
864
717
 
865
718
  let accessory
866
719
  if (route.externalCategory) {
@@ -882,161 +735,12 @@ export default class {
882
735
  // Set the logging level for this device
883
736
  this.applyAccessoryLogging(accessory)
884
737
 
885
- // Add the temperatureSource config to the context if exists
886
- if (deviceConf.temperatureSource) {
887
- accessory.context.temperatureSource = deviceConf.temperatureSource
888
- }
889
-
890
- // Add the openApiTempUnit config to the context if set to something other than auto
891
- if (deviceConf.openApiTempUnit && deviceConf.openApiTempUnit !== 'auto') {
892
- accessory.context.openApiTempUnit = deviceConf.openApiTempUnit
893
- } else {
894
- delete accessory.context.openApiTempUnit
895
- }
896
-
897
- // Get a supported command list if provided, with their options
898
- if (device.supportCmds && Array.isArray(device.supportCmds)) {
899
- accessory.context.supportedCmds = device.supportCmds
900
- accessory.context.supportedCmdsOpts = {}
901
-
902
- device.supportCmds.forEach((cmd) => {
903
- if (device?.properties?.[cmd]) {
904
- accessory.context.supportedCmdsOpts[cmd] = device.properties[cmd]
905
- }
906
- })
907
- }
908
-
909
- // Add some initial context information which is changed later
910
- accessory.context.hasAwsControl = false
911
- accessory.context.useAwsControl = false
912
- accessory.context.hasOpenApiControl = false
913
- accessory.context.useOpenApiControl = false
914
- accessory.context.hasBleControl = false
915
- accessory.context.useBleControl = false
916
- accessory.context.hasLanControl = device.isLanDevice
917
- accessory.context.useLanControl = accessory.context.hasLanControl
918
- accessory.context.firmware = false
919
- accessory.context.hardware = false
920
- accessory.context.image = false
921
-
922
- // Overrides for when a custom IP is provided, for a light which is not BLE only
923
- if (
924
- deviceConf.customIPAddress
925
- && accessory.context.hasLanControl
926
- && accessory.context.hasAwsControl
927
- && platformConsts.models.rgb.includes(device.model)
928
- ) {
929
- accessory.context.hasLanControl = true
930
- accessory.context.useLanControl = true
931
- }
932
-
933
- // If the device is LAN-only, then sync the display name with the label in the configuration
934
- if (device.isLanOnly) {
935
- accessory.displayName = device.deviceName
936
- }
937
-
938
- // See if we have extra HTTP client info for this device
939
- if (device.httpInfo) {
940
- // Save the hardware and firmware versions
941
- accessory.context.firmware = device.httpInfo.versionSoft
942
- accessory.context.hardware = device.httpInfo.versionHard
943
-
944
- // It's possible to show a nice little icon of the device in the Homebridge UI
945
- if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.extResources) {
946
- try {
947
- const parsed = JSON.parse(device.httpInfo.deviceExt.extResources)
948
- if (parsed && parsed.skuUrl) {
949
- accessory.context.image = parsed.skuUrl
950
- }
951
- } catch {
952
- // Ignore malformed extResources
953
- }
954
- }
955
-
956
- // HTTP info lets us see if AWS/BLE connection methods are available
957
- if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.deviceSettings) {
958
- let parsed
959
- try {
960
- parsed = JSON.parse(device.httpInfo.deviceExt.deviceSettings)
961
- } catch {
962
- this.log.debugWarn('[%s] failed to parse deviceSettings, skipping AWS/BLE setup.', accessory.displayName)
963
- }
964
-
965
- // Check to see if AWS is possible
966
- if (parsed) {
967
- if (parsed.topic) {
968
- accessory.context.hasAwsControl = true
969
- accessory.context.awsTopic = parsed.topic
970
-
971
- if (this.awsClient) {
972
- accessory.context.useAwsControl = true
973
- accessory.context.awsBrightnessNoScale = deviceConf.awsBrightnessNoScale
974
- accessory.context.awsColourMode = deviceConf.awsColourMode || platformConsts.defaultValues.awsColourMode
975
- awsDevices.push(device.device)
976
-
977
- // Certain models need AWS polling
978
- if (doAWSPolling) {
979
- awsDevicesToPoll.push(device.device)
980
- }
981
- }
982
- }
983
-
984
- // Check to see if BLE is possible
985
- if (parsed.bleName) {
986
- const providedBle = parsed.address ? parsed.address.toLowerCase() : device.device.substring(6).toLowerCase()
987
- accessory.context.hasBleControl = !!parsed.bleName
988
- accessory.context.bleAddress = deviceConf.customAddress
989
- ? deviceConf.customAddress.toLowerCase()
990
- : providedBle
991
- accessory.context.bleName = parsed.bleName
992
- accessory.context.bleWriteUuid = getDeviceCapabilities(accessory.context.gvModel).bleWriteUuid
993
- if (this.bleClient) {
994
- accessory.context.useBleControl = true
995
- }
996
- }
997
-
998
- // Gateway subdevices (e.g. leak sensors paired to an H5044) include
999
- // their gateway id and slot number, which we need to match real-time
1000
- // leak events relayed by the gateway over AWS (#1276)
1001
- if (hasProperty(parsed, 'sno') && parsed.gatewayInfo?.device) {
1002
- accessory.context.gatewaySno = parsed.sno
1003
- accessory.context.gatewayDevice = parsed.gatewayInfo.device
1004
- }
1005
-
1006
- // Get a min and max temperature/humidity range to show in the homebridge-ui
1007
- if (hasProperty(parsed, 'temCali')) {
1008
- accessory.context.minTemp = parsed.temMin / 100
1009
- accessory.context.maxTemp = parsed.temMax / 100
1010
- accessory.context.offTemp = parsed.temCali
1011
- }
1012
- if (hasProperty(parsed, 'humCali')) {
1013
- accessory.context.minHumi = parsed.humMin / 100
1014
- accessory.context.maxHumi = parsed.humMax / 100
1015
- accessory.context.offHumi = parsed.humCali
1016
- }
1017
- }
1018
- }
1019
- }
1020
-
1021
- if (device.openApiInfo) {
1022
- accessory.context.hasOpenApiControl = true
1023
- accessory.context.useOpenApiControl = !!this.openApiClient
1024
- accessory.context.openApiCapabilities = device.openApiInfo.byInstance || {}
1025
-
1026
- // Log what Govee says this device can do. Working out whether a model
1027
- // exposes something like a night light otherwise means reverse
1028
- // engineering its status messages, when the answer is right here
1029
- const instances = Object.entries(accessory.context.openApiCapabilities)
1030
- .map(([instance, capability]) => `${instance} (${capability?.type ?? 'unknown type'})`)
1031
- this.log.debug(
1032
- '[%s] openapi category [%s] capabilities [%s].',
1033
- accessory.displayName,
1034
- device.openApiInfo.category || 'unknown',
1035
- instances.join(', ') || 'none',
1036
- )
1037
- }
738
+ applyDeviceContext(this, accessory, device, deviceConf, {
739
+ awsDevices,
740
+ awsDevicesToPoll,
741
+ doAWSPolling: route.awsPolling,
742
+ })
1038
743
 
1039
- // Create the instance for this device type
1040
744
  accessory.control = new devInstance(this, accessory)
1041
745
 
1042
746
  // Log the device initialisation
@@ -0,0 +1,179 @@
1
+ import platformConsts from './constants.js'
2
+ import { getDeviceCapabilities } from './device-capabilities.js'
3
+ import { hasProperty } from './functions.js'
4
+
5
+ /**
6
+ * Fills in everything a handler needs to know about a device before it is
7
+ * built: which connections are available, what firmware it reports, the limits
8
+ * it works within, and what Govee's api says it can do.
9
+ *
10
+ * Lifted out of the platform unchanged. It is data assembly rather than
11
+ * anything to do with Homebridge, and it was making the one method that sets a
12
+ * device up twice as long as it needed to be.
13
+ *
14
+ * @param {object} platform the platform, for logging and settings
15
+ * @param {object} accessory the accessory being set up
16
+ * @param {object} device the device as Govee describes it
17
+ * @param {object} deviceConf this device's settings
18
+ * @param {object} joins where to record the connections this device can use -
19
+ * `awsDevices`, `awsDevicesToPoll`, and whether this kind of device needs its
20
+ * status asking for over AWS
21
+ */
22
+ export function applyDeviceContext(platform, accessory, device, deviceConf, joins) {
23
+ const { awsDevices, awsDevicesToPoll, doAWSPolling } = joins
24
+ // Add the temperatureSource config to the context if exists
25
+ if (deviceConf.temperatureSource) {
26
+ accessory.context.temperatureSource = deviceConf.temperatureSource
27
+ }
28
+
29
+ // Add the openApiTempUnit config to the context if set to something other than auto
30
+ if (deviceConf.openApiTempUnit && deviceConf.openApiTempUnit !== 'auto') {
31
+ accessory.context.openApiTempUnit = deviceConf.openApiTempUnit
32
+ } else {
33
+ delete accessory.context.openApiTempUnit
34
+ }
35
+
36
+ // Get a supported command list if provided, with their options
37
+ if (device.supportCmds && Array.isArray(device.supportCmds)) {
38
+ accessory.context.supportedCmds = device.supportCmds
39
+ accessory.context.supportedCmdsOpts = {}
40
+
41
+ device.supportCmds.forEach((cmd) => {
42
+ if (device?.properties?.[cmd]) {
43
+ accessory.context.supportedCmdsOpts[cmd] = device.properties[cmd]
44
+ }
45
+ })
46
+ }
47
+
48
+ // Add some initial context information which is changed later
49
+ accessory.context.hasAwsControl = false
50
+ accessory.context.useAwsControl = false
51
+ accessory.context.hasOpenApiControl = false
52
+ accessory.context.useOpenApiControl = false
53
+ accessory.context.hasBleControl = false
54
+ accessory.context.useBleControl = false
55
+ accessory.context.hasLanControl = device.isLanDevice
56
+ accessory.context.useLanControl = accessory.context.hasLanControl
57
+ accessory.context.firmware = false
58
+ accessory.context.hardware = false
59
+ accessory.context.image = false
60
+
61
+ // Overrides for when a custom IP is provided, for a light which is not BLE only
62
+ if (
63
+ deviceConf.customIPAddress
64
+ && accessory.context.hasLanControl
65
+ && accessory.context.hasAwsControl
66
+ && platformConsts.models.rgb.includes(device.model)
67
+ ) {
68
+ accessory.context.hasLanControl = true
69
+ accessory.context.useLanControl = true
70
+ }
71
+
72
+ // If the device is LAN-only, then sync the display name with the label in the configuration
73
+ if (device.isLanOnly) {
74
+ accessory.displayName = device.deviceName
75
+ }
76
+
77
+ // See if we have extra HTTP client info for this device
78
+ if (device.httpInfo) {
79
+ // Save the hardware and firmware versions
80
+ accessory.context.firmware = device.httpInfo.versionSoft
81
+ accessory.context.hardware = device.httpInfo.versionHard
82
+
83
+ // It's possible to show a nice little icon of the device in the Homebridge UI
84
+ if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.extResources) {
85
+ try {
86
+ const parsed = JSON.parse(device.httpInfo.deviceExt.extResources)
87
+ if (parsed && parsed.skuUrl) {
88
+ accessory.context.image = parsed.skuUrl
89
+ }
90
+ } catch {
91
+ // Ignore malformed extResources
92
+ }
93
+ }
94
+
95
+ // HTTP info lets us see if AWS/BLE connection methods are available
96
+ if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.deviceSettings) {
97
+ let parsed
98
+ try {
99
+ parsed = JSON.parse(device.httpInfo.deviceExt.deviceSettings)
100
+ } catch {
101
+ platform.log.debugWarn('[%s] failed to parse deviceSettings, skipping AWS/BLE setup.', accessory.displayName)
102
+ }
103
+
104
+ // Check to see if AWS is possible
105
+ if (parsed) {
106
+ if (parsed.topic) {
107
+ accessory.context.hasAwsControl = true
108
+ accessory.context.awsTopic = parsed.topic
109
+
110
+ if (platform.awsClient) {
111
+ accessory.context.useAwsControl = true
112
+ accessory.context.awsBrightnessNoScale = deviceConf.awsBrightnessNoScale
113
+ accessory.context.awsColourMode = deviceConf.awsColourMode || platformConsts.defaultValues.awsColourMode
114
+ awsDevices.push(device.device)
115
+
116
+ // Certain models need AWS polling
117
+ if (doAWSPolling) {
118
+ awsDevicesToPoll.push(device.device)
119
+ }
120
+ }
121
+ }
122
+
123
+ // Check to see if BLE is possible
124
+ if (parsed.bleName) {
125
+ const providedBle = parsed.address ? parsed.address.toLowerCase() : device.device.substring(6).toLowerCase()
126
+ accessory.context.hasBleControl = !!parsed.bleName
127
+ accessory.context.bleAddress = deviceConf.customAddress
128
+ ? deviceConf.customAddress.toLowerCase()
129
+ : providedBle
130
+ accessory.context.bleName = parsed.bleName
131
+ accessory.context.bleWriteUuid = getDeviceCapabilities(accessory.context.gvModel).bleWriteUuid
132
+ if (platform.bleClient) {
133
+ accessory.context.useBleControl = true
134
+ }
135
+ }
136
+
137
+ // Gateway subdevices (e.g. leak sensors paired to an H5044) include
138
+ // their gateway id and slot number, which we need to match real-time
139
+ // leak events relayed by the gateway over AWS (#1276)
140
+ if (hasProperty(parsed, 'sno') && parsed.gatewayInfo?.device) {
141
+ accessory.context.gatewaySno = parsed.sno
142
+ accessory.context.gatewayDevice = parsed.gatewayInfo.device
143
+ }
144
+
145
+ // Get a min and max temperature/humidity range to show in the homebridge-ui
146
+ if (hasProperty(parsed, 'temCali')) {
147
+ accessory.context.minTemp = parsed.temMin / 100
148
+ accessory.context.maxTemp = parsed.temMax / 100
149
+ accessory.context.offTemp = parsed.temCali
150
+ }
151
+ if (hasProperty(parsed, 'humCali')) {
152
+ accessory.context.minHumi = parsed.humMin / 100
153
+ accessory.context.maxHumi = parsed.humMax / 100
154
+ accessory.context.offHumi = parsed.humCali
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ if (device.openApiInfo) {
161
+ accessory.context.hasOpenApiControl = true
162
+ accessory.context.useOpenApiControl = !!platform.openApiClient
163
+ accessory.context.openApiCapabilities = device.openApiInfo.byInstance || {}
164
+
165
+ // Log what Govee says this device can do. Working out whether a model
166
+ // exposes something like a night light otherwise means reverse
167
+ // engineering its status messages, when the answer is right here
168
+ const instances = Object.entries(accessory.context.openApiCapabilities)
169
+ .map(([instance, capability]) => `${instance} (${capability?.type ?? 'unknown type'})`)
170
+ platform.log.debug(
171
+ '[%s] openapi category [%s] capabilities [%s].',
172
+ accessory.displayName,
173
+ device.openApiInfo.category || 'unknown',
174
+ instances.join(', ') || 'none',
175
+ )
176
+ }
177
+
178
+ // Create the instance for this device type
179
+ }
@@ -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.16",
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",