@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.
- package/CHANGELOG.md +31 -0
- package/config.schema.json +1 -1
- package/lib/connection/ble.js +26 -1
- package/lib/connection/openapi.js +18 -0
- package/lib/device/appliance-basic.js +377 -0
- package/lib/device/dehumidifier-H7150.js +9 -3
- package/lib/device/diffuser-H7161.js +7 -1
- package/lib/device/fan-H1370.js +7 -1
- package/lib/device/fan-H7100.js +36 -4
- package/lib/device/fan-H7105.js +7 -1
- package/lib/device/fan-H7107.js +7 -1
- package/lib/device/heater1a.js +52 -12
- package/lib/device/heater1b.js +14 -3
- package/lib/device/heater2.js +7 -1
- package/lib/device/humidifier-H7140.js +7 -1
- package/lib/device/humidifier-H7141.js +7 -1
- package/lib/device/humidifier-H7142.js +7 -1
- package/lib/device/humidifier-H7143.js +7 -1
- package/lib/device/humidifier-H7160.js +7 -1
- package/lib/device/ice-maker-H7172.js +19 -7
- package/lib/device/index.js +44 -51
- package/lib/device/kettle.js +7 -1
- package/lib/device/outlet-single.js +7 -1
- package/lib/device/purifier-H7120.js +14 -25
- package/lib/device/purifier-H7122.js +7 -1
- package/lib/device/purifier-H7123.js +9 -3
- package/lib/device/purifier-H7126.js +7 -1
- package/lib/device/{sensor-button.js → sensor-basic.js} +14 -2
- package/lib/device/sensor-presence.js +7 -1
- package/lib/device/sensor-thermo4.js +13 -2
- package/lib/device/switch-double.js +34 -15
- package/lib/device/switch-triple.js +36 -17
- package/lib/device/template.js +15 -2
- package/lib/platform.js +83 -799
- package/lib/utils/capabilities.js +220 -0
- package/lib/utils/command-builder.js +6 -4
- package/lib/utils/config-parser.js +280 -0
- package/lib/utils/constants.js +120 -8
- package/lib/utils/decode.js +4 -1
- package/lib/utils/device-capabilities.js +77 -3
- package/lib/utils/device-context.js +179 -0
- package/lib/utils/device-merge.js +251 -0
- package/lib/utils/dispatch.js +159 -0
- package/lib/utils/functions.js +9 -0
- package/lib/utils/lang-en.js +0 -2
- package/lib/utils/report-unknown.js +156 -0
- package/package.json +1 -1
- package/lib/device/dehumidifier-H7151.js +0 -189
- package/lib/device/dehumidifier-H7152.js +0 -180
- package/lib/device/diffuser-H7162.js +0 -123
- package/lib/device/fan-H1310.js +0 -284
- package/lib/device/fan-H7101.js +0 -340
- package/lib/device/fan-H7102.js +0 -284
- package/lib/device/fan-H7106.js +0 -284
- package/lib/device/fan-H7111.js +0 -345
- package/lib/device/humidifier-H7145.js +0 -187
- package/lib/device/humidifier-H7147.js +0 -187
- package/lib/device/humidifier-H7148.js +0 -187
- package/lib/device/humidifier-H7149.js +0 -187
- package/lib/device/humidifier-H714E.js +0 -187
- package/lib/device/ice-maker-H717D.js +0 -218
- package/lib/device/ice-maker-H8120.js +0 -218
- package/lib/device/outlet-double.js +0 -188
- package/lib/device/outlet-triple.js +0 -160
- package/lib/device/purifier-H7121.js +0 -406
- package/lib/device/purifier-H7124.js +0 -504
- package/lib/device/purifier-H7127.js +0 -359
- package/lib/device/purifier-H7128.js +0 -359
- package/lib/device/purifier-H7129.js +0 -359
- package/lib/device/purifier-H712C.js +0 -359
- package/lib/device/sensor-contact.js +0 -22
- package/lib/utils/ble-timeouts.test.js +0 -44
- 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 }
|
package/lib/utils/functions.js
CHANGED
|
@@ -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
|
package/lib/utils/lang-en.js
CHANGED
|
@@ -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