@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
@@ -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 }
@@ -0,0 +1,159 @@
1
+ import deviceTypes from '../device/index.js'
2
+ import platformConsts from './constants.js'
3
+
4
+ /**
5
+ * Works out which handler a device should get.
6
+ *
7
+ * This used to be a long chain of `else if` inside the platform, which meant
8
+ * adding a device type involved editing the file that also does config parsing,
9
+ * discovery and connection setup. It is a table now, read top to bottom, so a
10
+ * new type is a row.
11
+ *
12
+ * Only the choice lives here. Creating the accessory stays with the platform,
13
+ * because that talks to Homebridge - so this returns what to build rather than
14
+ * building it, and can be tested on its own.
15
+ */
16
+
17
+ /**
18
+ * How a model list picks its handler.
19
+ *
20
+ * - `handler` one handler for every model in the list
21
+ * - `prefix` look for a handler named after the model, eg `deviceFanH7100`
22
+ * - `fallback` used when `prefix` finds nothing, so a model nobody has worked
23
+ * out still gets power rather than being left unsupported
24
+ * - `choose` for the few lists where the answer depends on the device or the
25
+ * owner's settings
26
+ * - `awsPolling` these devices need their status asking for over AWS
27
+ */
28
+ const routes = [
29
+ {
30
+ list: 'rgb',
31
+ choose: ({ deviceConf }) => (deviceConf.showAs === 'switch' ? 'deviceLightSwitch' : 'deviceLight'),
32
+ },
33
+ { list: 'switchSingle', choose: chooseSingleSwitch },
34
+ {
35
+ list: 'switchDouble',
36
+ choose: ({ deviceConf }) => (showAs(deviceConf) === 'switch' ? 'deviceSwitchDouble' : 'deviceOutletDouble'),
37
+ },
38
+ {
39
+ list: 'switchTriple',
40
+ choose: ({ deviceConf }) => (showAs(deviceConf) === 'switch' ? 'deviceSwitchTriple' : 'deviceOutletTriple'),
41
+ },
42
+ { list: 'sensorLeak', handler: 'deviceSensorLeak' },
43
+ { list: 'sensorPresence', handler: 'deviceSensorPresence' },
44
+ {
45
+ list: 'sensorThermo',
46
+ choose: ({ deviceConf }) => (deviceConf.showExtraSwitch ? 'deviceSensorThermoSwitch' : 'deviceSensorThermo'),
47
+ },
48
+ { list: 'sensorThermo4', handler: 'deviceSensorThermo4' },
49
+ { list: 'sensorMonitor', handler: 'deviceSensorMonitor', awsPolling: true },
50
+ { list: 'sensorCO2', handler: 'deviceSensorCO2', awsPolling: true },
51
+ { list: 'fan', prefix: 'deviceFan', fallback: 'deviceFanBasic' },
52
+ { list: 'heater1', choose: chooseHeater, awsPolling: true },
53
+ { list: 'heater2', handler: 'deviceHeater2', awsPolling: true },
54
+ { list: 'humidifier', prefix: 'deviceHumidifier', fallback: 'deviceHumidifierBasic', awsPolling: true },
55
+ { list: 'dehumidifier', prefix: 'deviceDehumidifier', fallback: 'deviceDehumidifierBasic', awsPolling: true },
56
+ { list: 'purifier', prefix: 'devicePurifier', fallback: 'devicePurifierBasic', awsPolling: true },
57
+ { list: 'diffuser', prefix: 'deviceDiffuser', fallback: 'deviceDiffuserBasic', awsPolling: true },
58
+ { list: 'sensorButton', handler: 'deviceSensorButton' },
59
+ { list: 'sensorContact', handler: 'deviceSensorContact' },
60
+ { list: 'kettle', handler: 'deviceKettle' },
61
+ { list: 'iceMaker', prefix: 'deviceIceMaker', fallback: 'deviceIceMakerBasic' },
62
+ { list: 'template', handler: 'deviceTemplate' },
63
+ ]
64
+
65
+ function showAs(deviceConf) {
66
+ return deviceConf.showAs || platformConsts.defaultValues.showAs
67
+ }
68
+
69
+ /**
70
+ * A single socket can be shown as all sorts of things, because people use them
71
+ * for all sorts of things. Three of those are televisions, which Homebridge has
72
+ * to publish as their own accessory rather than as a tile on the bridge.
73
+ */
74
+ function chooseSingleSwitch({ deviceConf }) {
75
+ switch (showAs(deviceConf)) {
76
+ case 'audio':
77
+ return { handler: 'deviceTVSingle', externalCategory: 34 }
78
+ case 'box':
79
+ return { handler: 'deviceTVSingle', externalCategory: 35 }
80
+ case 'stick':
81
+ return { handler: 'deviceTVSingle', externalCategory: 36 }
82
+ case 'cooler':
83
+ // Standing in for a cooler means reading a temperature from somewhere
84
+ // else, and without that there is nothing to show
85
+ return deviceConf.temperatureSource
86
+ ? { handler: 'deviceCoolerSingle' }
87
+ : { skip: 'needsTemperatureSource' }
88
+ case 'heater':
89
+ return deviceConf.temperatureSource
90
+ ? { handler: 'deviceHeater2Single' }
91
+ : { skip: 'needsTemperatureSource' }
92
+ case 'purifier':
93
+ return 'devicePurifierSingle'
94
+ case 'switch':
95
+ return 'deviceSwitchSingle'
96
+ case 'tap':
97
+ return 'deviceTapSingle'
98
+ case 'valve':
99
+ return 'deviceValveSingle'
100
+ default:
101
+ return 'deviceOutletSingle'
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Some of these heaters report the room temperature and some do not, and the
107
+ * two need different tiles - a plain fan against a heater with a temperature
108
+ * readout. Rather than leave the owner to work that out and tick a box, take
109
+ * Govee's own word for it where the device says so, and otherwise use what a
110
+ * previous run learnt from the readings themselves. An owner who has already
111
+ * set the option still wins, so nobody's tile changes under them.
112
+ */
113
+ function chooseHeater({ device, deviceConf, cachedContext }) {
114
+ const reportsTemperature = device.openApiInfo?.byInstance?.sensorTemperature
115
+ ? true
116
+ : cachedContext?.supportsAmbientTemp
117
+ return (deviceConf.tempReporting ?? reportsTemperature)
118
+ ? 'deviceHeater1B'
119
+ : 'deviceHeater1A'
120
+ }
121
+
122
+ function normalise(answer) {
123
+ return typeof answer === 'string' ? { handler: answer } : answer
124
+ }
125
+
126
+ /**
127
+ * Decide what to build for a device.
128
+ *
129
+ * @param {object} device the device as Govee describes it
130
+ * @param {object} [deviceConf] this device's settings
131
+ * @param {object} [cachedContext] the context of the accessory from a previous
132
+ * run, where there is one - some choices depend on what was learnt before
133
+ * @returns {object|null} `{ handler, handlerKey, awsPolling, externalCategory,
134
+ * skip }`, or null when the model is not one we handle
135
+ */
136
+ export function resolveDeviceType(device, deviceConf = {}, cachedContext = undefined) {
137
+ const route = routes.find(entry => platformConsts.models[entry.list]?.includes(device.model))
138
+ if (!route) {
139
+ return null
140
+ }
141
+
142
+ const chosen = route.choose
143
+ ? normalise(route.choose({ device, deviceConf, cachedContext }))
144
+ : { handler: route.handler ?? (deviceTypes[`${route.prefix}${device.model}`] ? `${route.prefix}${device.model}` : route.fallback) }
145
+
146
+ if (chosen.skip) {
147
+ return { skip: chosen.skip, list: route.list }
148
+ }
149
+
150
+ return {
151
+ list: route.list,
152
+ handlerKey: chosen.handler,
153
+ handler: deviceTypes[chosen.handler],
154
+ awsPolling: !!route.awsPolling,
155
+ externalCategory: chosen.externalCategory,
156
+ }
157
+ }
158
+
159
+ export { routes }
@@ -22,6 +22,15 @@ const farToCen = temp => Math.round(((temp - 32) * 5) / 9)
22
22
  */
23
23
  function generateCodeFromHexValues(hexValues, returnAsHexBuffer = false) {
24
24
  const cmdSection = Buffer.from(hexValues.flat())
25
+
26
+ // More than 19 bytes leaves no room for the checksum. Without this the
27
+ // padding length goes negative, which produces an empty pad rather than an
28
+ // error, and the result is a packet longer than 20 bytes that the device
29
+ // will not understand - with nothing said about why
30
+ if (cmdSection.length > 19) {
31
+ throw new Error(`Command is ${cmdSection.length} bytes, which is longer than a packet allows`)
32
+ }
33
+
25
34
  const padSection = Buffer.from(Array.from({ length: 19 - cmdSection.length }).fill(0))
26
35
  const noXSection = Buffer.concat([cmdSection, padSection])
27
36
  let checksum = 0
@@ -106,7 +106,6 @@ export default {
106
106
  loginSuccess: 'login successful',
107
107
  logoutFail: 'could not log out of Govee account as',
108
108
  needHTTPClient: 'requires HTTP client',
109
- newScene: 'new scene code',
110
109
  noCreds: 'username and/or password not provided',
111
110
  noConnMethod: 'no connection method available, see https://shorturl.at/eCN04 for more info',
112
111
  noDevices: 'no data received from Govee server whilst obtaining devices',
@@ -134,5 +133,4 @@ export default {
134
133
  syncStillRunning: 'skipping this sync as the previous one has not finished yet',
135
134
  viaAL: 'via adaptive lighting',
136
135
  statusCodes: 'device status codes',
137
- unknownCommand: 'unknown command in payload received',
138
136
  }
@@ -0,0 +1,156 @@
1
+ import { base64ToHex } from './functions.js'
2
+
3
+ /**
4
+ * Logging for anything the plugin receives but does not understand yet.
5
+ *
6
+ * Adding support for a new device or a new function almost always stalls on the
7
+ * same thing: the maintainer has to go back and forth with the owner asking for
8
+ * the model, the firmware version, which connection the data arrived on, and
9
+ * the raw payload. This builds all of that into a single line the owner can
10
+ * copy straight into a GitHub issue, so the first report is usually enough.
11
+ *
12
+ * The line is deliberately one piece of JSON rather than prose - it is one
13
+ * copy-paste for the user, and it can be read back without picking it apart.
14
+ */
15
+
16
+ // Remembers what has already been reported, so a code the device repeats every
17
+ // few seconds is only shouted about once. Keyed by device, kind and payload, so
18
+ // a genuinely different unknown value is still surfaced.
19
+ const alreadyReported = new Set()
20
+
21
+ /**
22
+ * Reset the record of what has been reported. Only needed by tests, and when
23
+ * the platform restarts and wants a fresh session.
24
+ */
25
+ export function resetReportedUnknowns() {
26
+ alreadyReported.clear()
27
+ }
28
+
29
+ /**
30
+ * True the first time a given thing is seen, false every time after.
31
+ *
32
+ * For the places that have something worth reporting but no accessory to hang
33
+ * it off, such as a sensor broadcasting nearby that the plugin cannot decode.
34
+ * Lets those log loudly once and quietly thereafter, the same as the rest.
35
+ *
36
+ * @param {string} fingerprint identifies the thing being reported
37
+ */
38
+ export function isFirstReport(fingerprint) {
39
+ if (alreadyReported.has(fingerprint)) {
40
+ return false
41
+ }
42
+ alreadyReported.add(fingerprint)
43
+ return true
44
+ }
45
+
46
+ /**
47
+ * Report a control the plugin knows the device has, but does not yet know the
48
+ * command for, and then fail the request.
49
+ *
50
+ * This is how a model gets added before every last detail is known. The things
51
+ * that are certain - usually power - work straight away, and anything still
52
+ * unknown says so out loud instead of pretending to work. Failing the request
53
+ * matters: HomeKit shows the control as not responding, which sends the owner
54
+ * to the log, where they find the line to paste into an issue. A control that
55
+ * silently did nothing would just look broken.
56
+ *
57
+ * @param {object} accessory the accessory the control belongs to
58
+ * @param {object} details
59
+ * @param {string} details.control what was asked for, eg 'fan speed'
60
+ * @param {*} [details.value] the value HomeKit asked for
61
+ * @returns {string} the message that was logged, mainly for tests
62
+ */
63
+ export function reportUnsupportedControl(accessory, { control, value } = {}) {
64
+ const context = accessory?.context || {}
65
+ const report = {
66
+ model: context.gvModel,
67
+ firmware: context.firmware,
68
+ hardware: context.hardware,
69
+ control,
70
+ requested: value,
71
+ }
72
+ Object.keys(report).forEach((key) => {
73
+ if (report[key] === undefined || report[key] === null || report[key] === '') {
74
+ delete report[key]
75
+ }
76
+ })
77
+
78
+ const line = `the ${control} for this model is not known yet, so this did not happen - please report this line so it can be added: ${JSON.stringify(report)}`
79
+ const fingerprint = `${context.gvDeviceId || accessory?.displayName}|unsupported|${control}`
80
+
81
+ if (alreadyReported.has(fingerprint)) {
82
+ accessory?.logDebug?.(line)
83
+ } else {
84
+ alreadyReported.add(fingerprint)
85
+ accessory?.logWarn?.(line)
86
+ }
87
+
88
+ return line
89
+ }
90
+
91
+ /**
92
+ * Log an unrecognised payload with everything needed to add support for it.
93
+ *
94
+ * @param {object} accessory the accessory the data arrived for
95
+ * @param {object} details
96
+ * @param {string} details.kind what was not understood, eg 'scene' or 'command'
97
+ * @param {string} [details.source] the connection it came in on, eg 'AWS'
98
+ * @param {*} [details.raw] the payload exactly as received
99
+ * @param {string} [details.hex] the payload as hex, worked out from raw if omitted
100
+ * @param {object} [details.extra] any further fields worth including
101
+ */
102
+ export function logUnknownData(accessory, {
103
+ kind,
104
+ source,
105
+ raw,
106
+ hex,
107
+ extra,
108
+ } = {}) {
109
+ if (!accessory) {
110
+ return
111
+ }
112
+
113
+ const context = accessory.context || {}
114
+
115
+ // Work out the hex form when the payload is base64, since the hex is what
116
+ // actually gets read when working out what a code means.
117
+ let hexValue = hex
118
+ if (!hexValue && typeof raw === 'string') {
119
+ try {
120
+ hexValue = base64ToHex(raw)
121
+ } catch {
122
+ // Not base64, so the raw value is the useful form on its own
123
+ }
124
+ }
125
+
126
+ const report = {
127
+ model: context.gvModel,
128
+ firmware: context.firmware,
129
+ hardware: context.hardware,
130
+ source: source || 'unknown',
131
+ kind,
132
+ raw: typeof raw === 'object' ? JSON.stringify(raw) : raw,
133
+ ...(hexValue && hexValue !== raw ? { hex: hexValue } : {}),
134
+ ...extra,
135
+ }
136
+
137
+ // Drop anything we do not actually have, so the line stays readable
138
+ Object.keys(report).forEach((key) => {
139
+ if (report[key] === undefined || report[key] === null || report[key] === '') {
140
+ delete report[key]
141
+ }
142
+ })
143
+
144
+ const line = `unrecognised ${kind} - please include this line if you report it: ${JSON.stringify(report)}`
145
+ const fingerprint = `${context.gvDeviceId || accessory.displayName}|${kind}|${report.raw}`
146
+
147
+ // Say it once loudly enough to be noticed, then keep quiet about that exact
148
+ // payload for the rest of the session.
149
+ if (alreadyReported.has(fingerprint)) {
150
+ accessory.logDebug?.(line)
151
+ return
152
+ }
153
+
154
+ alreadyReported.add(fingerprint)
155
+ accessory.logWarn?.(line)
156
+ }
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.2",
5
+ "version": "11.32.2-beta.21",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",