@homebridge-plugins/homebridge-govee 11.32.2-beta.2 → 11.32.2-beta.21

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/config.schema.json +1 -1
  3. package/lib/connection/ble.js +26 -1
  4. package/lib/connection/openapi.js +18 -0
  5. package/lib/device/appliance-basic.js +377 -0
  6. package/lib/device/dehumidifier-H7150.js +9 -3
  7. package/lib/device/diffuser-H7161.js +7 -1
  8. package/lib/device/fan-H1370.js +7 -1
  9. package/lib/device/fan-H7100.js +36 -4
  10. package/lib/device/fan-H7105.js +7 -1
  11. package/lib/device/fan-H7107.js +7 -1
  12. package/lib/device/heater1a.js +52 -12
  13. package/lib/device/heater1b.js +14 -3
  14. package/lib/device/heater2.js +7 -1
  15. package/lib/device/humidifier-H7140.js +7 -1
  16. package/lib/device/humidifier-H7141.js +7 -1
  17. package/lib/device/humidifier-H7142.js +7 -1
  18. package/lib/device/humidifier-H7143.js +7 -1
  19. package/lib/device/humidifier-H7160.js +7 -1
  20. package/lib/device/ice-maker-H7172.js +19 -7
  21. package/lib/device/index.js +44 -51
  22. package/lib/device/kettle.js +7 -1
  23. package/lib/device/outlet-single.js +7 -1
  24. package/lib/device/purifier-H7120.js +14 -25
  25. package/lib/device/purifier-H7122.js +7 -1
  26. package/lib/device/purifier-H7123.js +9 -3
  27. package/lib/device/purifier-H7126.js +7 -1
  28. package/lib/device/{sensor-button.js → sensor-basic.js} +14 -2
  29. package/lib/device/sensor-presence.js +7 -1
  30. package/lib/device/sensor-thermo4.js +13 -2
  31. package/lib/device/switch-double.js +34 -15
  32. package/lib/device/switch-triple.js +36 -17
  33. package/lib/device/template.js +15 -2
  34. package/lib/platform.js +83 -799
  35. package/lib/utils/capabilities.js +220 -0
  36. package/lib/utils/command-builder.js +6 -4
  37. package/lib/utils/config-parser.js +280 -0
  38. package/lib/utils/constants.js +120 -8
  39. package/lib/utils/decode.js +4 -1
  40. package/lib/utils/device-capabilities.js +77 -3
  41. package/lib/utils/device-context.js +179 -0
  42. package/lib/utils/device-merge.js +251 -0
  43. package/lib/utils/dispatch.js +159 -0
  44. package/lib/utils/functions.js +9 -0
  45. package/lib/utils/lang-en.js +0 -2
  46. package/lib/utils/report-unknown.js +156 -0
  47. package/package.json +1 -1
  48. package/lib/device/dehumidifier-H7151.js +0 -189
  49. package/lib/device/dehumidifier-H7152.js +0 -180
  50. package/lib/device/diffuser-H7162.js +0 -123
  51. package/lib/device/fan-H1310.js +0 -284
  52. package/lib/device/fan-H7101.js +0 -340
  53. package/lib/device/fan-H7102.js +0 -284
  54. package/lib/device/fan-H7106.js +0 -284
  55. package/lib/device/fan-H7111.js +0 -345
  56. package/lib/device/humidifier-H7145.js +0 -187
  57. package/lib/device/humidifier-H7147.js +0 -187
  58. package/lib/device/humidifier-H7148.js +0 -187
  59. package/lib/device/humidifier-H7149.js +0 -187
  60. package/lib/device/humidifier-H714E.js +0 -187
  61. package/lib/device/ice-maker-H717D.js +0 -218
  62. package/lib/device/ice-maker-H8120.js +0 -218
  63. package/lib/device/outlet-double.js +0 -188
  64. package/lib/device/outlet-triple.js +0 -160
  65. package/lib/device/purifier-H7121.js +0 -406
  66. package/lib/device/purifier-H7124.js +0 -504
  67. package/lib/device/purifier-H7127.js +0 -359
  68. package/lib/device/purifier-H7128.js +0 -359
  69. package/lib/device/purifier-H7129.js +0 -359
  70. package/lib/device/purifier-H712C.js +0 -359
  71. package/lib/device/sensor-contact.js +0 -22
  72. package/lib/utils/ble-timeouts.test.js +0 -44
  73. package/lib/utils/functions.test.js +0 -292
package/lib/platform.js CHANGED
@@ -11,23 +11,25 @@ import awsClient from './connection/aws.js'
11
11
  import httpClient from './connection/http.js'
12
12
  import lanClient from './connection/lan.js'
13
13
  import openApiClient from './connection/openapi.js'
14
- import deviceTypes from './device/index.js'
15
14
  import eveService from './fakegato/fakegato-history.js'
16
15
  import { BLE_UPDATE_TIMEOUT } from './utils/ble-protocol.js'
17
16
  import { buildCommand } from './utils/command-builder.js'
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'
22
+ import { resolveDeviceType } from './utils/dispatch.js'
21
23
  import eveChars from './utils/eve-chars.js'
22
24
  import {
23
25
  base64ToHex,
24
26
  hasProperty,
25
- parseDeviceId,
26
27
  parseError,
27
28
  pfxToCertAndKey,
28
29
  updateFailureError,
29
30
  } from './utils/functions.js'
30
31
  import platformLang from './utils/lang-en.js'
32
+ import { logUnknownData } from './utils/report-unknown.js'
31
33
  import { parseDeviceUpdate } from './utils/response-parser.js'
32
34
 
33
35
  const require = createRequire(import.meta.url)
@@ -85,7 +87,7 @@ export default class {
85
87
 
86
88
  // Apply the user's configuration
87
89
  this.config = platformConsts.defaultConfig
88
- this.applyUserConfig(config)
90
+ applyUserConfig(this, config)
89
91
 
90
92
  // Set up empty clients
91
93
  this.bleClient = false
@@ -103,269 +105,6 @@ export default class {
103
105
  }
104
106
  }
105
107
 
106
- applyUserConfig(config) {
107
- // These shorthand functions save line space during config parsing
108
- const logDefault = (k, def) => {
109
- this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def)
110
- }
111
- const logDuplicate = (k) => {
112
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup)
113
- }
114
- const logIgnore = (k) => {
115
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn)
116
- }
117
- const logIgnoreItem = (k) => {
118
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem)
119
- }
120
- const logIncrease = (k, min) => {
121
- this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min)
122
- }
123
- const logQuotes = (k) => {
124
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts)
125
- }
126
- const logRemove = (k) => {
127
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv)
128
- }
129
-
130
- // Begin applying the user's config
131
- Object.entries(config).forEach((entry) => {
132
- const [key, val] = entry
133
- switch (key) {
134
- case 'apiKey':
135
- case 'code':
136
- case 'password':
137
- case 'username':
138
- if (typeof val !== 'string' || val === '') {
139
- logIgnore(key)
140
- } else {
141
- this.config[key] = val
142
- }
143
- break
144
- case 'lanScanSubnets': {
145
- // Comma-separated list of CIDR subnets to scan directly (for devices
146
- // on a different subnet from Homebridge, which multicast can't reach)
147
- if (typeof val !== 'string' || val.trim() === '') {
148
- break
149
- }
150
- const subnets = val
151
- .split(',')
152
- .map(s => s.trim())
153
- .filter(s => /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(s))
154
- if (subnets.length === 0) {
155
- logIgnore(key)
156
- } else {
157
- this.config[key] = subnets
158
- }
159
- break
160
- }
161
- case 'awsDisable':
162
- case 'bleDisable':
163
- case 'colourSafeMode':
164
- case 'disableDeviceLogging':
165
- case 'ignoreMatter':
166
- case 'lanDisable':
167
- if (typeof val === 'string') {
168
- logQuotes(key)
169
- }
170
- this.config[key] = val === 'false' ? false : !!val
171
- break
172
- case 'bleControlInterval':
173
- case 'bleRefreshTime':
174
- case 'httpRefreshTime':
175
- case 'lanRefreshTime':
176
- case 'lanScanInterval': {
177
- if (typeof val === 'string') {
178
- logQuotes(key)
179
- }
180
- const intVal = Number.parseInt(val, 10)
181
- if (Number.isNaN(intVal)) {
182
- logDefault(key, platformConsts.defaultValues[key])
183
- this.config[key] = platformConsts.defaultValues[key]
184
- } else if (intVal < platformConsts.minValues[key]) {
185
- logIncrease(key, platformConsts.minValues[key])
186
- this.config[key] = platformConsts.minValues[key]
187
- } else {
188
- this.config[key] = intVal
189
- }
190
- break
191
- }
192
- case 'dehumidifierDevices':
193
- case 'fanDevices':
194
- case 'heaterDevices':
195
- case 'humidifierDevices':
196
- case 'iceMakerDevices':
197
- case 'kettleDevices':
198
- case 'leakDevices':
199
- case 'lightDevices':
200
- case 'purifierDevices':
201
- case 'diffuserDevices':
202
- case 'switchDevices':
203
- case 'thermoDevices':
204
- if (Array.isArray(val) && val.length > 0) {
205
- val.forEach((x) => {
206
- if (!x.deviceId) {
207
- logIgnoreItem(key)
208
- return
209
- }
210
- const id = parseDeviceId(x.deviceId)
211
- if (Object.keys(this.deviceConf).includes(id)) {
212
- logDuplicate(`${key}.${id}`)
213
- return
214
- }
215
- const entries = Object.entries(x)
216
- if (entries.length === 1) {
217
- logRemove(`${key}.${id}`)
218
- return
219
- }
220
- this.deviceConf[id] = {}
221
- entries.forEach((subEntry) => {
222
- const [k, v] = subEntry
223
- switch (k) {
224
- case 'adaptiveLightingShift':
225
- case 'brightnessStep':
226
- case 'lowBattThreshold': {
227
- if (typeof v === 'string') {
228
- logQuotes(`${key}.${k}`)
229
- }
230
- const intVal = Number.parseInt(v, 10)
231
- if (Number.isNaN(intVal)) {
232
- logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
233
- this.deviceConf[id][k] = platformConsts.defaultValues[k]
234
- } else if (intVal < platformConsts.minValues[k]) {
235
- logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k])
236
- this.deviceConf[id][k] = platformConsts.minValues[k]
237
- } else {
238
- this.deviceConf[id][k] = intVal
239
- }
240
- break
241
- }
242
- case 'awsBrightnessNoScale':
243
- case 'hideLight':
244
- case 'hideModeGreenTea':
245
- case 'hideModeOolongTea':
246
- case 'hideModeCoffee':
247
- case 'hideModeBlackTea':
248
- case 'showCustomMode1':
249
- case 'showCustomMode2':
250
- case 'showExtraSwitch':
251
- case 'tempReporting':
252
- if (typeof v === 'string') {
253
- logQuotes(`${key}.${id}.${k}`)
254
- }
255
- this.deviceConf[id][k] = v === 'false' ? false : !!v
256
- break
257
- case 'awsColourMode':
258
- case 'openApiTempUnit':
259
- case 'showAs': {
260
- if (typeof v !== 'string' || !platformConsts.allowed[k].includes(v)) {
261
- logIgnore(`${key}.${id}.${k}`)
262
- } else {
263
- this.deviceConf[id][k] = v
264
- }
265
- break
266
- }
267
- case 'customAddress':
268
- case 'customIPAddress':
269
- if (typeof v !== 'string' || v === '') {
270
- logIgnore(`${key}.${id}.${k}`)
271
- } else {
272
- this.deviceConf[id][k] = v.replace(WHITESPACE_REGEX, '')
273
- }
274
- break
275
- case 'deviceId':
276
- break
277
- case 'diyMode':
278
- case 'diyModeTwo':
279
- case 'diyModeThree':
280
- case 'diyModeFour':
281
- case 'musicMode':
282
- case 'musicModeTwo':
283
- case 'scene':
284
- case 'sceneTwo':
285
- case 'sceneThree':
286
- case 'sceneFour':
287
- case 'segmented':
288
- case 'segmentedTwo':
289
- case 'segmentedThree':
290
- case 'segmentedFour':
291
- case 'temperatureSource':
292
- case 'videoMode':
293
- case 'videoModeTwo': {
294
- if (typeof v === 'string') {
295
- this.log.warn(`${key}.${id}.${k} incorrectly configured - please use the config screen to reconfigure this item:`)
296
- this.log.warn(`${key}.${id}.${k}: ${v}`)
297
- }
298
- if (typeof v === 'object') {
299
- // object - only allowed keys are 'sceneCode', 'bleCode' and 'showAs'
300
- const subEntries = Object.entries(v)
301
- if (subEntries.length > 0) {
302
- this.deviceConf[id][k] = {}
303
- subEntries.forEach((subSubEntry) => {
304
- const [k1, v1] = subSubEntry
305
- switch (k1) {
306
- case 'bleCode':
307
- case 'sceneCode':
308
- if (typeof v1 !== 'string' || v1 === '') {
309
- logIgnore(`${key}.${id}.${k}.${k1}`)
310
- } else {
311
- this.deviceConf[id][k][k1] = v1
312
- }
313
- break
314
- case 'showAs': {
315
- if (typeof v1 !== 'string' || !['default', 'switch'].includes(v1)) {
316
- logIgnore(`${key}.${id}.${k}.${k1}`)
317
- } else {
318
- this.deviceConf[id][k][k1] = v1
319
- }
320
- break
321
- }
322
- default:
323
- logIgnore(`${key}.${id}.${k}.${k1}`)
324
- break
325
- }
326
- })
327
- } else {
328
- logIgnore(`${key}.${id}.${k}`)
329
- }
330
- } else {
331
- logIgnore(`${key}.${id}.${k}`)
332
- }
333
- break
334
- }
335
- case 'ignoreDevice':
336
- if (typeof v === 'string') {
337
- logQuotes(`${key}.${id}.${k}`)
338
- }
339
- if (!!v && v !== 'false') {
340
- this.ignoredDevices.push(id)
341
- }
342
- break
343
- case 'label':
344
- if (typeof v !== 'string' || v === '') {
345
- logIgnore(`${key}.${id}.${k}`)
346
- } else {
347
- this.deviceConf[id][k] = v
348
- }
349
- break
350
- default:
351
- logRemove(`${key}.${id}.${k}`)
352
- }
353
- })
354
- })
355
- } else {
356
- logIgnore(key)
357
- }
358
- break
359
- case 'name':
360
- case 'platform':
361
- break
362
- default:
363
- logRemove(key)
364
- break
365
- }
366
- })
367
- }
368
-
369
108
  async pluginSetup() {
370
109
  // Plugin has finished initialising so now onto setup
371
110
  try {
@@ -604,192 +343,45 @@ export default class {
604
343
  throwOnTimeout: true,
605
344
  })
606
345
 
607
- // Initialise the devices
608
- let bleSyncNeeded = false
609
- let httpSyncNeeded = false
610
- let lanDevicesWereInitialised = false
611
- let httpDevicesWereInitialised = false
612
- let openApiDevicesWereInitialised = false
613
-
614
- if (httpDevices && httpDevices.length > 0) {
615
- // We have some devices from HTTP client
616
- httpDevices.forEach((httpDevice) => {
617
- // Format device id
618
- if (!httpDevice.device.includes(':')) {
619
- // Eg converts abcd1234abcd1234 to AB:CD:12:34:AB:CD:12:34
620
- // For sensors with an add-on sensor like H5178
621
- // Eg converts abcd1234abcd1234_1 to AB:CD:12:34:AB:CD:12:34_1
622
- httpDevice.device = httpDevice.device.replace(DEVICE_ID_FORMAT_REGEX, '$&:').toUpperCase()
623
- }
624
-
625
- // Check it's not a user-ignored device
626
- if (this.ignoredDevices.includes(httpDevice.device)) {
627
- return
628
- }
629
-
630
- // Skip Govee app device groups as these are not real devices (#1309)
631
- if (['BaseGroup', 'SameModeGroup'].includes(httpDevice.sku)) {
632
- this.log.debug('[%s] %s.', httpDevice.deviceName, platformLang.devIsGroup)
633
- return
634
- }
635
-
636
- // Check it's not a matter-ignored device, if the config has been set
637
- if (platformConsts.matterModels.includes(httpDevice.sku) && this.config.ignoreMatter) {
638
- return
639
- }
640
-
641
- // Sets the flag to see if we need to set up the BLE/HTTP syncs
642
- if (platformConsts.models.sensorLeak.includes(httpDevice.sku)) {
643
- httpSyncNeeded = true
644
- }
645
- if (platformConsts.models.sensorThermo.includes(httpDevice.sku)) {
646
- bleSyncNeeded = true
647
- httpSyncNeeded = true
648
- }
649
-
650
- // Find any matching device from the LAN client
651
- const lanDevice = lanDevices.find(el => el.device === httpDevice.device)
652
-
653
- // Find any matching device from the OpenAPI client to merge capabilities
654
- const matchingOpenApiDevice = openApiDevices.find(el => el.device === httpDevice.device)
655
- const openApiMerge = matchingOpenApiDevice
656
- ? {
657
- openApiInfo: matchingOpenApiDevice.openApiInfo,
658
- properties: matchingOpenApiDevice.properties,
659
- supportCmds: matchingOpenApiDevice.supportCmds,
660
- }
661
- : {}
662
-
663
- if (lanDevice) {
664
- // Device exists in LAN data so add the http info to the object and initialise
665
- this.initialiseDevice({
666
- ...lanDevice,
667
- httpInfo: httpDevice,
668
- model: httpDevice.sku,
669
- deviceName: httpDevice.deviceName,
670
- isLanDevice: true,
671
- ...openApiMerge,
672
- })
673
- lanDevicesWereInitialised = true
674
- lanDevice.initialised = true
675
- } else {
676
- // Device doesn't exist in LAN data, but try to initialise as could be other device type
677
- this.initialiseDevice({
678
- device: httpDevice.device,
679
- deviceName: httpDevice.deviceName,
680
- model: httpDevice.sku,
681
- httpInfo: httpDevice,
682
- ...openApiMerge,
683
- })
684
- }
685
- httpDevicesWereInitialised = true
686
- })
687
- }
688
-
689
- if (openApiDevices && openApiDevices.length > 0) {
690
- openApiDevices.forEach((openApiDevice) => {
691
- // Skip Govee app device groups as these are not real devices (#1309)
692
- if (['BaseGroup', 'SameModeGroup'].includes(openApiDevice.model)) {
693
- this.log.debug('[%s] %s.', openApiDevice.deviceName, platformLang.devIsGroup)
694
- return
695
- }
696
-
697
- if (this.ignoredDevices.includes(openApiDevice.device)) {
698
- return
699
- }
700
-
701
- if (platformConsts.matterModels.includes(openApiDevice.model) && this.config.ignoreMatter) {
702
- return
703
- }
704
-
705
- if (httpDevices.some(httpDevice => httpDevice.device === openApiDevice.device)) {
706
- return
707
- }
708
-
709
- const lanDevice = lanDevices.find(el => el.device === openApiDevice.device)
710
-
711
- if (lanDevice) {
712
- this.initialiseDevice({
713
- ...lanDevice,
714
- deviceName: openApiDevice.deviceName,
715
- model: openApiDevice.model,
716
- openApiInfo: openApiDevice.openApiInfo,
717
- properties: openApiDevice.properties,
718
- supportCmds: openApiDevice.supportCmds,
719
- isLanDevice: true,
720
- })
721
- lanDevicesWereInitialised = true
722
- lanDevice.initialised = true
723
- } else {
724
- this.initialiseDevice(openApiDevice)
725
- }
726
-
727
- openApiDevicesWereInitialised = true
728
- })
729
- }
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
+ })
730
357
 
731
- // Some LAN devices may exist outside the HTTP client
732
- const pendingLANDevices = lanDevices.filter(el => !el.initialised)
733
- if (pendingLANDevices.length > 0) {
734
- // No devices from HTTP client, but LAN devices exist
735
- pendingLANDevices.forEach((lanDevice) => {
736
- // Check it's not a user-ignored device
737
- if (this.ignoredDevices.includes(lanDevice.device)) {
738
- return
739
- }
358
+ plan.skippedGroups.forEach(name => this.log.debug('[%s] %s.', name, platformLang.devIsGroup))
359
+ plan.devices.forEach(device => this.initialiseDevice(device))
740
360
 
741
- // Check it's not a matter-ignored device, if the config has been set, since a
742
- // device skipped by the http/openapi loops for this reason still has an
743
- // unmatched lan record, which would otherwise be added here and then removed
744
- // again by the cleanup below on every restart (#1315)
745
- if (this.config.ignoreMatter && platformConsts.matterModels.includes(lanDevice.sku)) {
746
- return
747
- }
361
+ const {
362
+ bleSyncNeeded,
363
+ httpSyncNeeded,
364
+ httpDevicesWereInitialised,
365
+ openApiDevicesWereInitialised,
366
+ lanDevicesWereInitialised,
367
+ } = plan
748
368
 
749
- // Initialise the device into Homebridge
750
- // Since LAN does not provide a name, we will use the configured label or device id
751
- this.initialiseDevice({
752
- device: lanDevice.device,
753
- deviceName: this.deviceConf?.[lanDevice.device]?.label || lanDevice.device.replaceAll(':', ''),
754
- model: lanDevice.sku || 'HXXXX', // In case the model is not provided
755
- isLanDevice: true,
756
- isLanOnly: true,
757
- })
758
- lanDevicesWereInitialised = true
759
- })
760
- }
761
-
762
- if (!lanDevicesWereInitialised && !httpDevicesWereInitialised && !openApiDevicesWereInitialised) {
763
- // No devices either from HTTP client nor LAN client
369
+ if (!plan.anyInitialised) {
764
370
  throw new Error(platformLang.noDevs)
765
371
  }
766
372
 
767
- // Check for redundant Homebridge accessories
768
- // Only treat 'not found in Govee' as a reason to remove an accessory when the
769
- // configured cloud clients actually returned their device lists this session,
770
- // otherwise a failed fetch would remove the accessory and destroy any HomeKit
771
- // scenes and automations it is part of (#1264)
772
- const cloudListsFetched = (!this.config.username || !!this.httpClient)
773
- && (!!this.config.openApiDisable || !this.config.apiKey || !!this.openApiClient)
774
- devicesInHB.forEach((accessory) => {
775
- // Remove the accessory if it is now being ignored, or if it is a matter model
776
- // and the user has enabled ignoreMatter (otherwise the cached accessory lingers
777
- // even though the device is skipped during setup) (#1287)
778
- const isIgnored = this.ignoredDevices.includes(accessory.context.gvDeviceId)
779
- || (this.config.ignoreMatter && platformConsts.matterModels.includes(accessory.context.gvModel))
780
-
781
- // Also remove it if the device no longer exists in Govee, but never based on
782
- // an incomplete cloud list, and never for LAN-only devices which may simply
783
- // have missed a scan window (#1264)
784
- const isMissing = !httpDevices.some(el => el.device === accessory.context.gvDeviceId)
785
- && !lanDevices.some(el => el.device === accessory.context.gvDeviceId)
786
- && !openApiDevices.some(el => el.device === accessory.context.gvDeviceId)
787
- const isLanOnly = accessory.context.hasLanControl && !accessory.context.firmware
788
-
789
- if (isIgnored || (isMissing && cloudListsFetched && !isLanOnly)) {
790
- this.removeAccessory(accessory)
791
- }
792
- })
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))
793
385
 
794
386
  // Set up the ble client sync needed for thermo sensor devices
795
387
  if (bleSyncNeeded) {
@@ -1094,204 +686,14 @@ export default class {
1094
686
  try {
1095
687
  const deviceConf = this.deviceConf[device.device.toUpperCase()] || {}
1096
688
  const uuid = this.api.hap.uuid.generate(device.device)
1097
- let accessory
1098
- let devInstance
1099
- let doAWSPolling = false
1100
- if (platformConsts.models.rgb.includes(device.model)) {
1101
- // Device is an LED strip/bulb
1102
- devInstance = deviceConf.showAs === 'switch'
1103
- ? deviceTypes.deviceLightSwitch
1104
- : deviceTypes.deviceLight
1105
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1106
- } else if (platformConsts.models.switchSingle.includes(device.model)) {
1107
- // Device is a cloud enabled Wi-Fi switch
1108
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1109
- case 'audio': {
1110
- if (devicesInHB.get(uuid)) {
1111
- this.removeAccessory(devicesInHB.get(uuid))
1112
- }
1113
- devInstance = deviceTypes.deviceTVSingle
1114
- accessory = this.addExternalAccessory(device, 34)
1115
- break
1116
- }
1117
- case 'box': {
1118
- if (devicesInHB.get(uuid)) {
1119
- this.removeAccessory(devicesInHB.get(uuid))
1120
- }
1121
- devInstance = deviceTypes.deviceTVSingle
1122
- accessory = this.addExternalAccessory(device, 35)
1123
- break
1124
- }
1125
- case 'stick': {
1126
- if (devicesInHB.get(uuid)) {
1127
- this.removeAccessory(devicesInHB.get(uuid))
1128
- }
1129
- devInstance = deviceTypes.deviceTVSingle
1130
- accessory = this.addExternalAccessory(device, 36)
1131
- break
1132
- }
1133
- case 'cooler': {
1134
- if (!deviceConf.temperatureSource) {
1135
- this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
1136
- if (devicesInHB.has(uuid)) {
1137
- this.removeAccessory(devicesInHB.get(uuid))
1138
- }
1139
- return
1140
- }
1141
- devInstance = deviceTypes.deviceCoolerSingle
1142
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1143
- break
1144
- }
1145
- case 'heater': {
1146
- if (!deviceConf.temperatureSource) {
1147
- this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
1148
- if (devicesInHB.has(uuid)) {
1149
- this.removeAccessory(devicesInHB.get(uuid))
1150
- }
1151
- return
1152
- }
1153
- devInstance = deviceTypes.deviceHeater2Single
1154
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1155
- break
1156
- }
1157
- case 'purifier': {
1158
- devInstance = deviceTypes.devicePurifierSingle
1159
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1160
- break
1161
- }
1162
- case 'switch': {
1163
- devInstance = deviceTypes.deviceSwitchSingle
1164
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1165
- break
1166
- }
1167
- case 'tap': {
1168
- devInstance = deviceTypes.deviceTapSingle
1169
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1170
- break
1171
- }
1172
- case 'valve': {
1173
- devInstance = deviceTypes.deviceValveSingle
1174
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1175
- break
1176
- }
1177
- default:
1178
- devInstance = deviceTypes.deviceOutletSingle
1179
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1180
- break
1181
- }
1182
- } else if (platformConsts.models.switchDouble.includes(device.model)) {
1183
- // Device is an AWS enabled Wi-Fi double switch
1184
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1185
- case 'switch': {
1186
- devInstance = deviceTypes.deviceSwitchDouble
1187
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1188
- break
1189
- }
1190
- default: {
1191
- devInstance = deviceTypes.deviceOutletDouble
1192
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1193
- break
1194
- }
1195
- }
1196
- } else if (platformConsts.models.switchTriple.includes(device.model)) {
1197
- // Device is an AWS enabled Wi-Fi double switch
1198
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1199
- case 'switch': {
1200
- devInstance = deviceTypes.deviceSwitchTriple
1201
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1202
- break
1203
- }
1204
- default: {
1205
- devInstance = deviceTypes.deviceOutletTriple
1206
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1207
- break
1208
- }
1209
- }
1210
- } else if (platformConsts.models.sensorLeak.includes(device.model)) {
1211
- // Device is a leak sensor
1212
- devInstance = deviceTypes.deviceSensorLeak
1213
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1214
- } else if (platformConsts.models.sensorPresence.includes(device.model)) {
1215
- // Device is a presence sensor
1216
- devInstance = deviceTypes.deviceSensorPresence
1217
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1218
- } else if (platformConsts.models.sensorThermo.includes(device.model)) {
1219
- // Device is a thermo-hygrometer sensor
1220
- devInstance = deviceConf.showExtraSwitch
1221
- ? deviceTypes.deviceSensorThermoSwitch
1222
- : deviceTypes.deviceSensorThermo
1223
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1224
- } else if (platformConsts.models.sensorThermo4.includes(device.model)) {
1225
- // Device is a thermo-hygrometer sensor with 4 prongs and AWS support
1226
- devInstance = deviceTypes.deviceSensorThermo4
1227
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1228
- } else if (platformConsts.models.sensorMonitor.includes(device.model)) {
1229
- devInstance = deviceTypes.deviceSensorMonitor
1230
- doAWSPolling = true
1231
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1232
- } else if (platformConsts.models.sensorCO2.includes(device.model)) {
1233
- // Device is a CO2 + temperature + humidity monitor (H5140)
1234
- devInstance = deviceTypes.deviceSensorCO2
1235
- doAWSPolling = true
1236
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1237
- } else if (platformConsts.models.fan.includes(device.model)) {
1238
- // Device is a fan
1239
- devInstance = deviceTypes[`deviceFan${device.model}`]
1240
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1241
- } else if (platformConsts.models.heater1.includes(device.model)) {
1242
- // Device is a H7130
1243
- devInstance = deviceConf.tempReporting
1244
- ? deviceTypes.deviceHeater1B
1245
- : deviceTypes.deviceHeater1A
1246
- doAWSPolling = true
1247
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1248
- } else if (platformConsts.models.heater2.includes(device.model)) {
1249
- // Device is a H7131/H7132
1250
- devInstance = deviceTypes.deviceHeater2
1251
- doAWSPolling = true
1252
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1253
- } else if (platformConsts.models.humidifier.includes(device.model)) {
1254
- // Device is a humidifier
1255
- doAWSPolling = true
1256
- devInstance = deviceTypes[`deviceHumidifier${device.model}`]
1257
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1258
- } else if (platformConsts.models.dehumidifier.includes(device.model)) {
1259
- // Device is a dehumidifier
1260
- devInstance = deviceTypes[`deviceDehumidifier${device.model}`]
1261
- doAWSPolling = true
1262
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1263
- } else if (platformConsts.models.purifier.includes(device.model)) {
1264
- // Device is a purifier
1265
- devInstance = deviceTypes[`devicePurifier${device.model}`]
1266
- doAWSPolling = true
1267
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1268
- } else if (platformConsts.models.diffuser.includes(device.model)) {
1269
- // Device is a diffuser
1270
- devInstance = deviceTypes[`deviceDiffuser${device.model}`]
1271
- doAWSPolling = true
1272
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1273
- } else if (platformConsts.models.sensorButton.includes(device.model)) {
1274
- // Device is a button
1275
- devInstance = deviceTypes.deviceSensorButton
1276
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1277
- } else if (platformConsts.models.sensorContact.includes(device.model)) {
1278
- // Device is a contact sensor
1279
- devInstance = deviceTypes.deviceSensorContact
1280
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1281
- } else if (platformConsts.models.kettle.includes(device.model)) {
1282
- // Device is a kettle
1283
- devInstance = deviceTypes.deviceKettle
1284
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1285
- } else if (platformConsts.models.iceMaker.includes(device.model)) {
1286
- // Device is an ice maker
1287
- devInstance = deviceTypes[`deviceIceMaker${device.model}`]
1288
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1289
- } else if (platformConsts.models.template.includes(device.model)) {
1290
- // Device is a work-in-progress
1291
- devInstance = deviceTypes.deviceTemplate
1292
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1293
- } else {
1294
- // Device is not in any supported model list but could be implemented into the plugin
689
+ const cached = devicesInHB.get(uuid)
690
+
691
+ // Which handler this device gets is decided by the dispatch table, so
692
+ // adding a device type does not mean editing this file
693
+ const route = resolveDeviceType(device, deviceConf, cached?.context)
694
+
695
+ if (!route) {
696
+ // Not in any supported model list, but it could be added to the plugin
1295
697
  this.log.warn(
1296
698
  '[%s] %s:\n%s',
1297
699
  device.deviceName,
@@ -1301,169 +703,44 @@ export default class {
1301
703
  return
1302
704
  }
1303
705
 
1304
- // Final check the accessory now exists in Homebridge
1305
- if (!accessory) {
1306
- throw new Error(platformLang.accNotFound)
706
+ if (route.skip === 'needsTemperatureSource') {
707
+ // Standing in for a heater or cooler needs a temperature from somewhere
708
+ // else, and without one there is nothing to show
709
+ this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
710
+ if (cached) {
711
+ this.removeAccessory(cached)
712
+ }
713
+ return
1307
714
  }
1308
715
 
1309
- // Set the logging level for this device
1310
- this.applyAccessoryLogging(accessory)
1311
-
1312
- // Add the temperatureSource config to the context if exists
1313
- if (deviceConf.temperatureSource) {
1314
- accessory.context.temperatureSource = deviceConf.temperatureSource
1315
- }
716
+ const devInstance = route.handler
1316
717
 
1317
- // Add the openApiTempUnit config to the context if set to something other than auto
1318
- if (deviceConf.openApiTempUnit && deviceConf.openApiTempUnit !== 'auto') {
1319
- accessory.context.openApiTempUnit = deviceConf.openApiTempUnit
718
+ let accessory
719
+ if (route.externalCategory) {
720
+ // A television has to be published as its own accessory rather than as
721
+ // a tile on the bridge, so any tile from a previous setting goes first
722
+ if (cached) {
723
+ this.removeAccessory(cached)
724
+ }
725
+ accessory = this.addExternalAccessory(device, route.externalCategory)
1320
726
  } else {
1321
- delete accessory.context.openApiTempUnit
727
+ accessory = cached || this.addAccessory(device)
1322
728
  }
1323
729
 
1324
- // Get a supported command list if provided, with their options
1325
- if (device.supportCmds && Array.isArray(device.supportCmds)) {
1326
- accessory.context.supportedCmds = device.supportCmds
1327
- accessory.context.supportedCmdsOpts = {}
1328
-
1329
- device.supportCmds.forEach((cmd) => {
1330
- if (device?.properties?.[cmd]) {
1331
- accessory.context.supportedCmdsOpts[cmd] = device.properties[cmd]
1332
- }
1333
- })
1334
- }
1335
-
1336
- // Add some initial context information which is changed later
1337
- accessory.context.hasAwsControl = false
1338
- accessory.context.useAwsControl = false
1339
- accessory.context.hasOpenApiControl = false
1340
- accessory.context.useOpenApiControl = false
1341
- accessory.context.hasBleControl = false
1342
- accessory.context.useBleControl = false
1343
- accessory.context.hasLanControl = device.isLanDevice
1344
- accessory.context.useLanControl = accessory.context.hasLanControl
1345
- accessory.context.firmware = false
1346
- accessory.context.hardware = false
1347
- accessory.context.image = false
1348
-
1349
- // Overrides for when a custom IP is provided, for a light which is not BLE only
1350
- if (
1351
- deviceConf.customIPAddress
1352
- && accessory.context.hasLanControl
1353
- && accessory.context.hasAwsControl
1354
- && platformConsts.models.rgb.includes(device.model)
1355
- ) {
1356
- accessory.context.hasLanControl = true
1357
- accessory.context.useLanControl = true
1358
- }
1359
-
1360
- // If the device is LAN-only, then sync the display name with the label in the configuration
1361
- if (device.isLanOnly) {
1362
- accessory.displayName = device.deviceName
730
+ // Final check the accessory now exists in Homebridge
731
+ if (!accessory) {
732
+ throw new Error(platformLang.accNotFound)
1363
733
  }
1364
734
 
1365
- // See if we have extra HTTP client info for this device
1366
- if (device.httpInfo) {
1367
- // Save the hardware and firmware versions
1368
- accessory.context.firmware = device.httpInfo.versionSoft
1369
- accessory.context.hardware = device.httpInfo.versionHard
1370
-
1371
- // It's possible to show a nice little icon of the device in the Homebridge UI
1372
- if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.extResources) {
1373
- try {
1374
- const parsed = JSON.parse(device.httpInfo.deviceExt.extResources)
1375
- if (parsed && parsed.skuUrl) {
1376
- accessory.context.image = parsed.skuUrl
1377
- }
1378
- } catch {
1379
- // Ignore malformed extResources
1380
- }
1381
- }
1382
-
1383
- // HTTP info lets us see if AWS/BLE connection methods are available
1384
- if (device.httpInfo.deviceExt && device.httpInfo.deviceExt.deviceSettings) {
1385
- let parsed
1386
- try {
1387
- parsed = JSON.parse(device.httpInfo.deviceExt.deviceSettings)
1388
- } catch {
1389
- this.log.debugWarn('[%s] failed to parse deviceSettings, skipping AWS/BLE setup.', accessory.displayName)
1390
- }
1391
-
1392
- // Check to see if AWS is possible
1393
- if (parsed) {
1394
- if (parsed.topic) {
1395
- accessory.context.hasAwsControl = true
1396
- accessory.context.awsTopic = parsed.topic
1397
-
1398
- if (this.awsClient) {
1399
- accessory.context.useAwsControl = true
1400
- accessory.context.awsBrightnessNoScale = deviceConf.awsBrightnessNoScale
1401
- accessory.context.awsColourMode = deviceConf.awsColourMode || platformConsts.defaultValues.awsColourMode
1402
- awsDevices.push(device.device)
1403
-
1404
- // Certain models need AWS polling
1405
- if (doAWSPolling) {
1406
- awsDevicesToPoll.push(device.device)
1407
- }
1408
- }
1409
- }
1410
-
1411
- // Check to see if BLE is possible
1412
- if (parsed.bleName) {
1413
- const providedBle = parsed.address ? parsed.address.toLowerCase() : device.device.substring(6).toLowerCase()
1414
- accessory.context.hasBleControl = !!parsed.bleName
1415
- accessory.context.bleAddress = deviceConf.customAddress
1416
- ? deviceConf.customAddress.toLowerCase()
1417
- : providedBle
1418
- accessory.context.bleName = parsed.bleName
1419
- accessory.context.bleWriteUuid = getDeviceCapabilities(accessory.context.gvModel).bleWriteUuid
1420
- if (this.bleClient) {
1421
- accessory.context.useBleControl = true
1422
- }
1423
- }
1424
-
1425
- // Gateway subdevices (e.g. leak sensors paired to an H5044) include
1426
- // their gateway id and slot number, which we need to match real-time
1427
- // leak events relayed by the gateway over AWS (#1276)
1428
- if (hasProperty(parsed, 'sno') && parsed.gatewayInfo?.device) {
1429
- accessory.context.gatewaySno = parsed.sno
1430
- accessory.context.gatewayDevice = parsed.gatewayInfo.device
1431
- }
1432
-
1433
- // Get a min and max temperature/humidity range to show in the homebridge-ui
1434
- if (hasProperty(parsed, 'temCali')) {
1435
- accessory.context.minTemp = parsed.temMin / 100
1436
- accessory.context.maxTemp = parsed.temMax / 100
1437
- accessory.context.offTemp = parsed.temCali
1438
- }
1439
- if (hasProperty(parsed, 'humCali')) {
1440
- accessory.context.minHumi = parsed.humMin / 100
1441
- accessory.context.maxHumi = parsed.humMax / 100
1442
- accessory.context.offHumi = parsed.humCali
1443
- }
1444
- }
1445
- }
1446
- }
735
+ // Set the logging level for this device
736
+ this.applyAccessoryLogging(accessory)
1447
737
 
1448
- if (device.openApiInfo) {
1449
- accessory.context.hasOpenApiControl = true
1450
- accessory.context.useOpenApiControl = !!this.openApiClient
1451
- accessory.context.openApiCapabilities = device.openApiInfo.byInstance || {}
1452
-
1453
- // Log what Govee says this device can do. Working out whether a model
1454
- // exposes something like a night light otherwise means reverse
1455
- // engineering its status messages, when the answer is right here
1456
- const instances = Object.entries(accessory.context.openApiCapabilities)
1457
- .map(([instance, capability]) => `${instance} (${capability?.type ?? 'unknown type'})`)
1458
- this.log.debug(
1459
- '[%s] openapi category [%s] capabilities [%s].',
1460
- accessory.displayName,
1461
- device.openApiInfo.category || 'unknown',
1462
- instances.join(', ') || 'none',
1463
- )
1464
- }
738
+ applyDeviceContext(this, accessory, device, deviceConf, {
739
+ awsDevices,
740
+ awsDevicesToPoll,
741
+ doAWSPolling: route.awsPolling,
742
+ })
1465
743
 
1466
- // Create the instance for this device type
1467
744
  accessory.control = new devInstance(this, accessory)
1468
745
 
1469
746
  // Log the device initialisation
@@ -2077,7 +1354,14 @@ export default class {
2077
1354
  this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotUpdated, parseError(err))
2078
1355
  }
2079
1356
  } else {
2080
- accessory.logDebugWarn(`[${params.source}] ${platformLang.unknownCommand}: ${JSON.stringify(params)}`)
1357
+ // Nothing in the payload was understood. This is the most useful thing a
1358
+ // user can send in when asking for a device or a function to be added, so
1359
+ // it is reported in full rather than hidden behind debug logging.
1360
+ logUnknownData(accessory, {
1361
+ kind: 'payload',
1362
+ source: params.source,
1363
+ raw: params,
1364
+ })
2081
1365
  }
2082
1366
  }
2083
1367
  }