@homebridge-plugins/homebridge-govee 11.32.2-beta.15 → 11.32.2-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -32,6 +32,9 @@ All notable changes to `@homebridge-plugins/homebridge-govee` will be documented
32
32
  - refactor: build the switch and outlet tiles of a multi-socket device from one handler instead of two copies of it
33
33
  - fix: move the H7152 dehumidifier's speed slider when it reports a new speed, which until now only the H7150 and H7151 did
34
34
  - refactor: point the H7101 and H7111 fans at the H7100 handler they matched command for command
35
+ - feat: give a diffuser model that has not been worked out yet the same starting handler the other appliances have, so it works for on and off
36
+ - refactor: choose a device handler from a table, so adding a device type no longer means editing the platform
37
+ - refactor: move the settings parsing into its own file, away from the code that talks to devices
35
38
 
36
39
  ## v11.32.1 (2026-08-02)
37
40
 
@@ -356,6 +356,16 @@ export const deviceDehumidifierBasic = makeBasicAppliance({
356
356
  currentStateOff: 0, // inactive
357
357
  })
358
358
 
359
+ // Diffusers are power only, so there is no variable control to offer. They use
360
+ // the same tile as the models already handled, so a new one looks the same
361
+ export const deviceDiffuserBasic = makeBasicAppliance({
362
+ family: 'diffuser',
363
+ service: 'AirPurifier',
364
+ currentStateChar: 'CurrentAirPurifierState',
365
+ currentStateOn: 2, // purifying air
366
+ currentStateOff: 0, // inactive
367
+ })
368
+
359
369
  export const devicePurifierBasic = makeBasicAppliance({
360
370
  family: 'purifier',
361
371
  service: 'AirPurifier',
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  deviceDehumidifierBasic,
3
+ deviceDiffuserBasic,
3
4
  deviceFanBasic,
4
5
  deviceHumidifierBasic,
5
6
  deviceIceMakerBasic,
@@ -55,6 +56,7 @@ export default {
55
56
  deviceDiffuserH7161,
56
57
  deviceDiffuserH7162: deviceDiffuserH7161, // identical to the H7161
57
58
  deviceDehumidifierBasic,
59
+ deviceDiffuserBasic,
58
60
  deviceFanBasic,
59
61
  deviceFanH1310: deviceFanH7100, // same fan, minus a confirmed swing status message
60
62
  deviceFanH1370,
package/lib/platform.js CHANGED
@@ -11,18 +11,18 @@ 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
20
  import { getDeviceCapabilities } from './utils/device-capabilities.js'
21
+ import { resolveDeviceType } from './utils/dispatch.js'
21
22
  import eveChars from './utils/eve-chars.js'
22
23
  import {
23
24
  base64ToHex,
24
25
  hasProperty,
25
- parseDeviceId,
26
26
  parseError,
27
27
  pfxToCertAndKey,
28
28
  updateFailureError,
@@ -86,7 +86,7 @@ export default class {
86
86
 
87
87
  // Apply the user's configuration
88
88
  this.config = platformConsts.defaultConfig
89
- this.applyUserConfig(config)
89
+ applyUserConfig(this, config)
90
90
 
91
91
  // Set up empty clients
92
92
  this.bleClient = false
@@ -104,269 +104,6 @@ export default class {
104
104
  }
105
105
  }
106
106
 
107
- applyUserConfig(config) {
108
- // These shorthand functions save line space during config parsing
109
- const logDefault = (k, def) => {
110
- this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def)
111
- }
112
- const logDuplicate = (k) => {
113
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup)
114
- }
115
- const logIgnore = (k) => {
116
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn)
117
- }
118
- const logIgnoreItem = (k) => {
119
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem)
120
- }
121
- const logIncrease = (k, min) => {
122
- this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min)
123
- }
124
- const logQuotes = (k) => {
125
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts)
126
- }
127
- const logRemove = (k) => {
128
- this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv)
129
- }
130
-
131
- // Begin applying the user's config
132
- Object.entries(config).forEach((entry) => {
133
- const [key, val] = entry
134
- switch (key) {
135
- case 'apiKey':
136
- case 'code':
137
- case 'password':
138
- case 'username':
139
- if (typeof val !== 'string' || val === '') {
140
- logIgnore(key)
141
- } else {
142
- this.config[key] = val
143
- }
144
- break
145
- case 'lanScanSubnets': {
146
- // Comma-separated list of CIDR subnets to scan directly (for devices
147
- // on a different subnet from Homebridge, which multicast can't reach)
148
- if (typeof val !== 'string' || val.trim() === '') {
149
- break
150
- }
151
- const subnets = val
152
- .split(',')
153
- .map(s => s.trim())
154
- .filter(s => /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(s))
155
- if (subnets.length === 0) {
156
- logIgnore(key)
157
- } else {
158
- this.config[key] = subnets
159
- }
160
- break
161
- }
162
- case 'awsDisable':
163
- case 'bleDisable':
164
- case 'colourSafeMode':
165
- case 'disableDeviceLogging':
166
- case 'ignoreMatter':
167
- case 'lanDisable':
168
- if (typeof val === 'string') {
169
- logQuotes(key)
170
- }
171
- this.config[key] = val === 'false' ? false : !!val
172
- break
173
- case 'bleControlInterval':
174
- case 'bleRefreshTime':
175
- case 'httpRefreshTime':
176
- case 'lanRefreshTime':
177
- case 'lanScanInterval': {
178
- if (typeof val === 'string') {
179
- logQuotes(key)
180
- }
181
- const intVal = Number.parseInt(val, 10)
182
- if (Number.isNaN(intVal)) {
183
- logDefault(key, platformConsts.defaultValues[key])
184
- this.config[key] = platformConsts.defaultValues[key]
185
- } else if (intVal < platformConsts.minValues[key]) {
186
- logIncrease(key, platformConsts.minValues[key])
187
- this.config[key] = platformConsts.minValues[key]
188
- } else {
189
- this.config[key] = intVal
190
- }
191
- break
192
- }
193
- case 'dehumidifierDevices':
194
- case 'fanDevices':
195
- case 'heaterDevices':
196
- case 'humidifierDevices':
197
- case 'iceMakerDevices':
198
- case 'kettleDevices':
199
- case 'leakDevices':
200
- case 'lightDevices':
201
- case 'purifierDevices':
202
- case 'diffuserDevices':
203
- case 'switchDevices':
204
- case 'thermoDevices':
205
- if (Array.isArray(val) && val.length > 0) {
206
- val.forEach((x) => {
207
- if (!x.deviceId) {
208
- logIgnoreItem(key)
209
- return
210
- }
211
- const id = parseDeviceId(x.deviceId)
212
- if (Object.keys(this.deviceConf).includes(id)) {
213
- logDuplicate(`${key}.${id}`)
214
- return
215
- }
216
- const entries = Object.entries(x)
217
- if (entries.length === 1) {
218
- logRemove(`${key}.${id}`)
219
- return
220
- }
221
- this.deviceConf[id] = {}
222
- entries.forEach((subEntry) => {
223
- const [k, v] = subEntry
224
- switch (k) {
225
- case 'adaptiveLightingShift':
226
- case 'brightnessStep':
227
- case 'lowBattThreshold': {
228
- if (typeof v === 'string') {
229
- logQuotes(`${key}.${k}`)
230
- }
231
- const intVal = Number.parseInt(v, 10)
232
- if (Number.isNaN(intVal)) {
233
- logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
234
- this.deviceConf[id][k] = platformConsts.defaultValues[k]
235
- } else if (intVal < platformConsts.minValues[k]) {
236
- logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k])
237
- this.deviceConf[id][k] = platformConsts.minValues[k]
238
- } else {
239
- this.deviceConf[id][k] = intVal
240
- }
241
- break
242
- }
243
- case 'awsBrightnessNoScale':
244
- case 'hideLight':
245
- case 'hideModeGreenTea':
246
- case 'hideModeOolongTea':
247
- case 'hideModeCoffee':
248
- case 'hideModeBlackTea':
249
- case 'showCustomMode1':
250
- case 'showCustomMode2':
251
- case 'showExtraSwitch':
252
- case 'tempReporting':
253
- if (typeof v === 'string') {
254
- logQuotes(`${key}.${id}.${k}`)
255
- }
256
- this.deviceConf[id][k] = v === 'false' ? false : !!v
257
- break
258
- case 'awsColourMode':
259
- case 'openApiTempUnit':
260
- case 'showAs': {
261
- if (typeof v !== 'string' || !platformConsts.allowed[k].includes(v)) {
262
- logIgnore(`${key}.${id}.${k}`)
263
- } else {
264
- this.deviceConf[id][k] = v
265
- }
266
- break
267
- }
268
- case 'customAddress':
269
- case 'customIPAddress':
270
- if (typeof v !== 'string' || v === '') {
271
- logIgnore(`${key}.${id}.${k}`)
272
- } else {
273
- this.deviceConf[id][k] = v.replace(WHITESPACE_REGEX, '')
274
- }
275
- break
276
- case 'deviceId':
277
- break
278
- case 'diyMode':
279
- case 'diyModeTwo':
280
- case 'diyModeThree':
281
- case 'diyModeFour':
282
- case 'musicMode':
283
- case 'musicModeTwo':
284
- case 'scene':
285
- case 'sceneTwo':
286
- case 'sceneThree':
287
- case 'sceneFour':
288
- case 'segmented':
289
- case 'segmentedTwo':
290
- case 'segmentedThree':
291
- case 'segmentedFour':
292
- case 'temperatureSource':
293
- case 'videoMode':
294
- case 'videoModeTwo': {
295
- if (typeof v === 'string') {
296
- this.log.warn(`${key}.${id}.${k} incorrectly configured - please use the config screen to reconfigure this item:`)
297
- this.log.warn(`${key}.${id}.${k}: ${v}`)
298
- }
299
- if (typeof v === 'object') {
300
- // object - only allowed keys are 'sceneCode', 'bleCode' and 'showAs'
301
- const subEntries = Object.entries(v)
302
- if (subEntries.length > 0) {
303
- this.deviceConf[id][k] = {}
304
- subEntries.forEach((subSubEntry) => {
305
- const [k1, v1] = subSubEntry
306
- switch (k1) {
307
- case 'bleCode':
308
- case 'sceneCode':
309
- if (typeof v1 !== 'string' || v1 === '') {
310
- logIgnore(`${key}.${id}.${k}.${k1}`)
311
- } else {
312
- this.deviceConf[id][k][k1] = v1
313
- }
314
- break
315
- case 'showAs': {
316
- if (typeof v1 !== 'string' || !['default', 'switch'].includes(v1)) {
317
- logIgnore(`${key}.${id}.${k}.${k1}`)
318
- } else {
319
- this.deviceConf[id][k][k1] = v1
320
- }
321
- break
322
- }
323
- default:
324
- logIgnore(`${key}.${id}.${k}.${k1}`)
325
- break
326
- }
327
- })
328
- } else {
329
- logIgnore(`${key}.${id}.${k}`)
330
- }
331
- } else {
332
- logIgnore(`${key}.${id}.${k}`)
333
- }
334
- break
335
- }
336
- case 'ignoreDevice':
337
- if (typeof v === 'string') {
338
- logQuotes(`${key}.${id}.${k}`)
339
- }
340
- if (!!v && v !== 'false') {
341
- this.ignoredDevices.push(id)
342
- }
343
- break
344
- case 'label':
345
- if (typeof v !== 'string' || v === '') {
346
- logIgnore(`${key}.${id}.${k}`)
347
- } else {
348
- this.deviceConf[id][k] = v
349
- }
350
- break
351
- default:
352
- logRemove(`${key}.${id}.${k}`)
353
- }
354
- })
355
- })
356
- } else {
357
- logIgnore(key)
358
- }
359
- break
360
- case 'name':
361
- case 'platform':
362
- break
363
- default:
364
- logRemove(key)
365
- break
366
- }
367
- })
368
- }
369
-
370
107
  async pluginSetup() {
371
108
  // Plugin has finished initialising so now onto setup
372
109
  try {
@@ -1095,215 +832,14 @@ export default class {
1095
832
  try {
1096
833
  const deviceConf = this.deviceConf[device.device.toUpperCase()] || {}
1097
834
  const uuid = this.api.hap.uuid.generate(device.device)
1098
- let accessory
1099
- let devInstance
1100
- let doAWSPolling = false
1101
- if (platformConsts.models.rgb.includes(device.model)) {
1102
- // Device is an LED strip/bulb
1103
- devInstance = deviceConf.showAs === 'switch'
1104
- ? deviceTypes.deviceLightSwitch
1105
- : deviceTypes.deviceLight
1106
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1107
- } else if (platformConsts.models.switchSingle.includes(device.model)) {
1108
- // Device is a cloud enabled Wi-Fi switch
1109
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1110
- case 'audio': {
1111
- if (devicesInHB.get(uuid)) {
1112
- this.removeAccessory(devicesInHB.get(uuid))
1113
- }
1114
- devInstance = deviceTypes.deviceTVSingle
1115
- accessory = this.addExternalAccessory(device, 34)
1116
- break
1117
- }
1118
- case 'box': {
1119
- if (devicesInHB.get(uuid)) {
1120
- this.removeAccessory(devicesInHB.get(uuid))
1121
- }
1122
- devInstance = deviceTypes.deviceTVSingle
1123
- accessory = this.addExternalAccessory(device, 35)
1124
- break
1125
- }
1126
- case 'stick': {
1127
- if (devicesInHB.get(uuid)) {
1128
- this.removeAccessory(devicesInHB.get(uuid))
1129
- }
1130
- devInstance = deviceTypes.deviceTVSingle
1131
- accessory = this.addExternalAccessory(device, 36)
1132
- break
1133
- }
1134
- case 'cooler': {
1135
- if (!deviceConf.temperatureSource) {
1136
- this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
1137
- if (devicesInHB.has(uuid)) {
1138
- this.removeAccessory(devicesInHB.get(uuid))
1139
- }
1140
- return
1141
- }
1142
- devInstance = deviceTypes.deviceCoolerSingle
1143
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1144
- break
1145
- }
1146
- case 'heater': {
1147
- if (!deviceConf.temperatureSource) {
1148
- this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
1149
- if (devicesInHB.has(uuid)) {
1150
- this.removeAccessory(devicesInHB.get(uuid))
1151
- }
1152
- return
1153
- }
1154
- devInstance = deviceTypes.deviceHeater2Single
1155
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1156
- break
1157
- }
1158
- case 'purifier': {
1159
- devInstance = deviceTypes.devicePurifierSingle
1160
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1161
- break
1162
- }
1163
- case 'switch': {
1164
- devInstance = deviceTypes.deviceSwitchSingle
1165
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1166
- break
1167
- }
1168
- case 'tap': {
1169
- devInstance = deviceTypes.deviceTapSingle
1170
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1171
- break
1172
- }
1173
- case 'valve': {
1174
- devInstance = deviceTypes.deviceValveSingle
1175
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1176
- break
1177
- }
1178
- default:
1179
- devInstance = deviceTypes.deviceOutletSingle
1180
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1181
- break
1182
- }
1183
- } else if (platformConsts.models.switchDouble.includes(device.model)) {
1184
- // Device is an AWS enabled Wi-Fi double switch
1185
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1186
- case 'switch': {
1187
- devInstance = deviceTypes.deviceSwitchDouble
1188
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1189
- break
1190
- }
1191
- default: {
1192
- devInstance = deviceTypes.deviceOutletDouble
1193
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1194
- break
1195
- }
1196
- }
1197
- } else if (platformConsts.models.switchTriple.includes(device.model)) {
1198
- // Device is an AWS enabled Wi-Fi double switch
1199
- switch (deviceConf.showAs || platformConsts.defaultValues.showAs) {
1200
- case 'switch': {
1201
- devInstance = deviceTypes.deviceSwitchTriple
1202
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1203
- break
1204
- }
1205
- default: {
1206
- devInstance = deviceTypes.deviceOutletTriple
1207
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1208
- break
1209
- }
1210
- }
1211
- } else if (platformConsts.models.sensorLeak.includes(device.model)) {
1212
- // Device is a leak sensor
1213
- devInstance = deviceTypes.deviceSensorLeak
1214
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1215
- } else if (platformConsts.models.sensorPresence.includes(device.model)) {
1216
- // Device is a presence sensor
1217
- devInstance = deviceTypes.deviceSensorPresence
1218
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1219
- } else if (platformConsts.models.sensorThermo.includes(device.model)) {
1220
- // Device is a thermo-hygrometer sensor
1221
- devInstance = deviceConf.showExtraSwitch
1222
- ? deviceTypes.deviceSensorThermoSwitch
1223
- : deviceTypes.deviceSensorThermo
1224
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1225
- } else if (platformConsts.models.sensorThermo4.includes(device.model)) {
1226
- // Device is a thermo-hygrometer sensor with 4 prongs and AWS support
1227
- devInstance = deviceTypes.deviceSensorThermo4
1228
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1229
- } else if (platformConsts.models.sensorMonitor.includes(device.model)) {
1230
- devInstance = deviceTypes.deviceSensorMonitor
1231
- doAWSPolling = true
1232
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1233
- } else if (platformConsts.models.sensorCO2.includes(device.model)) {
1234
- // Device is a CO2 + temperature + humidity monitor (H5140)
1235
- devInstance = deviceTypes.deviceSensorCO2
1236
- doAWSPolling = true
1237
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1238
- } else if (platformConsts.models.fan.includes(device.model)) {
1239
- // Device is a fan. Models whose speeds have not been worked out yet fall
1240
- // back to the basic handler, which offers on and off and asks for what
1241
- // it needs rather than leaving the device unsupported altogether
1242
- devInstance = deviceTypes[`deviceFan${device.model}`] || deviceTypes.deviceFanBasic
1243
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1244
- } else if (platformConsts.models.heater1.includes(device.model)) {
1245
- // Some of these heaters report the room temperature and some do not,
1246
- // and the two need different tiles in HomeKit - a plain fan against a
1247
- // heater with a temperature readout. Rather than leave the owner to
1248
- // work that out and tick a box, take Govee's own word for it where the
1249
- // device says so, and otherwise use what a previous run learnt from the
1250
- // readings themselves. The setting still wins if it has been set, so
1251
- // nobody who has already configured this sees their tile change.
1252
- const reportsTemperature = device.openApiInfo?.byInstance?.sensorTemperature
1253
- ? true
1254
- : devicesInHB.get(uuid)?.context?.supportsAmbientTemp
1255
- devInstance = (deviceConf.tempReporting ?? reportsTemperature)
1256
- ? deviceTypes.deviceHeater1B
1257
- : deviceTypes.deviceHeater1A
1258
- doAWSPolling = true
1259
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1260
- } else if (platformConsts.models.heater2.includes(device.model)) {
1261
- // Device is a H7131/H7132
1262
- devInstance = deviceTypes.deviceHeater2
1263
- doAWSPolling = true
1264
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1265
- } else if (platformConsts.models.humidifier.includes(device.model)) {
1266
- // Device is a humidifier
1267
- doAWSPolling = true
1268
- devInstance = deviceTypes[`deviceHumidifier${device.model}`] || deviceTypes.deviceHumidifierBasic
1269
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1270
- } else if (platformConsts.models.dehumidifier.includes(device.model)) {
1271
- // Device is a dehumidifier
1272
- devInstance = deviceTypes[`deviceDehumidifier${device.model}`] || deviceTypes.deviceDehumidifierBasic
1273
- doAWSPolling = true
1274
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1275
- } else if (platformConsts.models.purifier.includes(device.model)) {
1276
- // Device is a purifier
1277
- devInstance = deviceTypes[`devicePurifier${device.model}`] || deviceTypes.devicePurifierBasic
1278
- doAWSPolling = true
1279
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1280
- } else if (platformConsts.models.diffuser.includes(device.model)) {
1281
- // Device is a diffuser
1282
- devInstance = deviceTypes[`deviceDiffuser${device.model}`]
1283
- doAWSPolling = true
1284
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1285
- } else if (platformConsts.models.sensorButton.includes(device.model)) {
1286
- // Device is a button
1287
- devInstance = deviceTypes.deviceSensorButton
1288
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1289
- } else if (platformConsts.models.sensorContact.includes(device.model)) {
1290
- // Device is a contact sensor
1291
- devInstance = deviceTypes.deviceSensorContact
1292
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1293
- } else if (platformConsts.models.kettle.includes(device.model)) {
1294
- // Device is a kettle
1295
- devInstance = deviceTypes.deviceKettle
1296
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1297
- } else if (platformConsts.models.iceMaker.includes(device.model)) {
1298
- // Device is an ice maker
1299
- devInstance = deviceTypes[`deviceIceMaker${device.model}`] || deviceTypes.deviceIceMakerBasic
1300
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1301
- } else if (platformConsts.models.template.includes(device.model)) {
1302
- // Device is a work-in-progress
1303
- devInstance = deviceTypes.deviceTemplate
1304
- accessory = devicesInHB.get(uuid) || this.addAccessory(device)
1305
- } else {
1306
- // Device is not in any supported model list but could be implemented into the plugin
835
+ const cached = devicesInHB.get(uuid)
836
+
837
+ // Which handler this device gets is decided by the dispatch table, so
838
+ // adding a device type does not mean editing this file
839
+ const route = resolveDeviceType(device, deviceConf, cached?.context)
840
+
841
+ if (!route) {
842
+ // Not in any supported model list, but it could be added to the plugin
1307
843
  this.log.warn(
1308
844
  '[%s] %s:\n%s',
1309
845
  device.deviceName,
@@ -1313,6 +849,31 @@ export default class {
1313
849
  return
1314
850
  }
1315
851
 
852
+ if (route.skip === 'needsTemperatureSource') {
853
+ // Standing in for a heater or cooler needs a temperature from somewhere
854
+ // else, and without one there is nothing to show
855
+ this.log.warn('[%s] %s.', device.deviceName, platformLang.heaterSimNoSensor)
856
+ if (cached) {
857
+ this.removeAccessory(cached)
858
+ }
859
+ return
860
+ }
861
+
862
+ const devInstance = route.handler
863
+ const doAWSPolling = route.awsPolling
864
+
865
+ let accessory
866
+ if (route.externalCategory) {
867
+ // A television has to be published as its own accessory rather than as
868
+ // a tile on the bridge, so any tile from a previous setting goes first
869
+ if (cached) {
870
+ this.removeAccessory(cached)
871
+ }
872
+ accessory = this.addExternalAccessory(device, route.externalCategory)
873
+ } else {
874
+ accessory = cached || this.addAccessory(device)
875
+ }
876
+
1316
877
  // Final check the accessory now exists in Homebridge
1317
878
  if (!accessory) {
1318
879
  throw new Error(platformLang.accNotFound)
@@ -0,0 +1,280 @@
1
+ import platformConsts from './constants.js'
2
+ import { parseDeviceId } from './functions.js'
3
+ import platformLang from './lang-en.js'
4
+
5
+ const WHITESPACE_REGEX = /\s+/g
6
+
7
+ /**
8
+ * Reads the user's settings, warns about anything that will not be used, and
9
+ * fills in the defaults for the rest.
10
+ *
11
+ * Lifted out of the platform unchanged. It was the largest single thing in
12
+ * there and had nothing to do with talking to devices, so a change to how a
13
+ * device works no longer means opening the same file as a change to how a
14
+ * setting is read.
15
+ *
16
+ * It writes `config`, `deviceConf` and `ignoredDevices` onto the platform
17
+ * rather than returning them, which is how it always worked.
18
+ */
19
+ export function applyUserConfig(platform, config) {
20
+ // These shorthand functions save line space during config parsing
21
+ const logDefault = (k, def) => {
22
+ platform.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def)
23
+ }
24
+ const logDuplicate = (k) => {
25
+ platform.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup)
26
+ }
27
+ const logIgnore = (k) => {
28
+ platform.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn)
29
+ }
30
+ const logIgnoreItem = (k) => {
31
+ platform.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem)
32
+ }
33
+ const logIncrease = (k, min) => {
34
+ platform.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min)
35
+ }
36
+ const logQuotes = (k) => {
37
+ platform.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts)
38
+ }
39
+ const logRemove = (k) => {
40
+ platform.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv)
41
+ }
42
+
43
+ // Begin applying the user's config
44
+ Object.entries(config).forEach((entry) => {
45
+ const [key, val] = entry
46
+ switch (key) {
47
+ case 'apiKey':
48
+ case 'code':
49
+ case 'password':
50
+ case 'username':
51
+ if (typeof val !== 'string' || val === '') {
52
+ logIgnore(key)
53
+ } else {
54
+ platform.config[key] = val
55
+ }
56
+ break
57
+ case 'lanScanSubnets': {
58
+ // Comma-separated list of CIDR subnets to scan directly (for devices
59
+ // on a different subnet from Homebridge, which multicast can't reach)
60
+ if (typeof val !== 'string' || val.trim() === '') {
61
+ break
62
+ }
63
+ const subnets = val
64
+ .split(',')
65
+ .map(s => s.trim())
66
+ .filter(s => /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/.test(s))
67
+ if (subnets.length === 0) {
68
+ logIgnore(key)
69
+ } else {
70
+ platform.config[key] = subnets
71
+ }
72
+ break
73
+ }
74
+ case 'awsDisable':
75
+ case 'bleDisable':
76
+ case 'colourSafeMode':
77
+ case 'disableDeviceLogging':
78
+ case 'ignoreMatter':
79
+ case 'lanDisable':
80
+ if (typeof val === 'string') {
81
+ logQuotes(key)
82
+ }
83
+ platform.config[key] = val === 'false' ? false : !!val
84
+ break
85
+ case 'bleControlInterval':
86
+ case 'bleRefreshTime':
87
+ case 'httpRefreshTime':
88
+ case 'lanRefreshTime':
89
+ case 'lanScanInterval': {
90
+ if (typeof val === 'string') {
91
+ logQuotes(key)
92
+ }
93
+ const intVal = Number.parseInt(val, 10)
94
+ if (Number.isNaN(intVal)) {
95
+ logDefault(key, platformConsts.defaultValues[key])
96
+ platform.config[key] = platformConsts.defaultValues[key]
97
+ } else if (intVal < platformConsts.minValues[key]) {
98
+ logIncrease(key, platformConsts.minValues[key])
99
+ platform.config[key] = platformConsts.minValues[key]
100
+ } else {
101
+ platform.config[key] = intVal
102
+ }
103
+ break
104
+ }
105
+ case 'dehumidifierDevices':
106
+ case 'fanDevices':
107
+ case 'heaterDevices':
108
+ case 'humidifierDevices':
109
+ case 'iceMakerDevices':
110
+ case 'kettleDevices':
111
+ case 'leakDevices':
112
+ case 'lightDevices':
113
+ case 'purifierDevices':
114
+ case 'diffuserDevices':
115
+ case 'switchDevices':
116
+ case 'thermoDevices':
117
+ if (Array.isArray(val) && val.length > 0) {
118
+ val.forEach((x) => {
119
+ if (!x.deviceId) {
120
+ logIgnoreItem(key)
121
+ return
122
+ }
123
+ const id = parseDeviceId(x.deviceId)
124
+ if (Object.keys(platform.deviceConf).includes(id)) {
125
+ logDuplicate(`${key}.${id}`)
126
+ return
127
+ }
128
+ const entries = Object.entries(x)
129
+ if (entries.length === 1) {
130
+ logRemove(`${key}.${id}`)
131
+ return
132
+ }
133
+ platform.deviceConf[id] = {}
134
+ entries.forEach((subEntry) => {
135
+ const [k, v] = subEntry
136
+ switch (k) {
137
+ case 'adaptiveLightingShift':
138
+ case 'brightnessStep':
139
+ case 'lowBattThreshold': {
140
+ if (typeof v === 'string') {
141
+ logQuotes(`${key}.${k}`)
142
+ }
143
+ const intVal = Number.parseInt(v, 10)
144
+ if (Number.isNaN(intVal)) {
145
+ logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
146
+ platform.deviceConf[id][k] = platformConsts.defaultValues[k]
147
+ } else if (intVal < platformConsts.minValues[k]) {
148
+ logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k])
149
+ platform.deviceConf[id][k] = platformConsts.minValues[k]
150
+ } else {
151
+ platform.deviceConf[id][k] = intVal
152
+ }
153
+ break
154
+ }
155
+ case 'awsBrightnessNoScale':
156
+ case 'hideLight':
157
+ case 'hideModeGreenTea':
158
+ case 'hideModeOolongTea':
159
+ case 'hideModeCoffee':
160
+ case 'hideModeBlackTea':
161
+ case 'showCustomMode1':
162
+ case 'showCustomMode2':
163
+ case 'showExtraSwitch':
164
+ case 'tempReporting':
165
+ if (typeof v === 'string') {
166
+ logQuotes(`${key}.${id}.${k}`)
167
+ }
168
+ platform.deviceConf[id][k] = v === 'false' ? false : !!v
169
+ break
170
+ case 'awsColourMode':
171
+ case 'openApiTempUnit':
172
+ case 'showAs': {
173
+ if (typeof v !== 'string' || !platformConsts.allowed[k].includes(v)) {
174
+ logIgnore(`${key}.${id}.${k}`)
175
+ } else {
176
+ platform.deviceConf[id][k] = v
177
+ }
178
+ break
179
+ }
180
+ case 'customAddress':
181
+ case 'customIPAddress':
182
+ if (typeof v !== 'string' || v === '') {
183
+ logIgnore(`${key}.${id}.${k}`)
184
+ } else {
185
+ platform.deviceConf[id][k] = v.replace(WHITESPACE_REGEX, '')
186
+ }
187
+ break
188
+ case 'deviceId':
189
+ break
190
+ case 'diyMode':
191
+ case 'diyModeTwo':
192
+ case 'diyModeThree':
193
+ case 'diyModeFour':
194
+ case 'musicMode':
195
+ case 'musicModeTwo':
196
+ case 'scene':
197
+ case 'sceneTwo':
198
+ case 'sceneThree':
199
+ case 'sceneFour':
200
+ case 'segmented':
201
+ case 'segmentedTwo':
202
+ case 'segmentedThree':
203
+ case 'segmentedFour':
204
+ case 'temperatureSource':
205
+ case 'videoMode':
206
+ case 'videoModeTwo': {
207
+ if (typeof v === 'string') {
208
+ platform.log.warn(`${key}.${id}.${k} incorrectly configured - please use the config screen to reconfigure this item:`)
209
+ platform.log.warn(`${key}.${id}.${k}: ${v}`)
210
+ }
211
+ if (typeof v === 'object') {
212
+ // object - only allowed keys are 'sceneCode', 'bleCode' and 'showAs'
213
+ const subEntries = Object.entries(v)
214
+ if (subEntries.length > 0) {
215
+ platform.deviceConf[id][k] = {}
216
+ subEntries.forEach((subSubEntry) => {
217
+ const [k1, v1] = subSubEntry
218
+ switch (k1) {
219
+ case 'bleCode':
220
+ case 'sceneCode':
221
+ if (typeof v1 !== 'string' || v1 === '') {
222
+ logIgnore(`${key}.${id}.${k}.${k1}`)
223
+ } else {
224
+ platform.deviceConf[id][k][k1] = v1
225
+ }
226
+ break
227
+ case 'showAs': {
228
+ if (typeof v1 !== 'string' || !['default', 'switch'].includes(v1)) {
229
+ logIgnore(`${key}.${id}.${k}.${k1}`)
230
+ } else {
231
+ platform.deviceConf[id][k][k1] = v1
232
+ }
233
+ break
234
+ }
235
+ default:
236
+ logIgnore(`${key}.${id}.${k}.${k1}`)
237
+ break
238
+ }
239
+ })
240
+ } else {
241
+ logIgnore(`${key}.${id}.${k}`)
242
+ }
243
+ } else {
244
+ logIgnore(`${key}.${id}.${k}`)
245
+ }
246
+ break
247
+ }
248
+ case 'ignoreDevice':
249
+ if (typeof v === 'string') {
250
+ logQuotes(`${key}.${id}.${k}`)
251
+ }
252
+ if (!!v && v !== 'false') {
253
+ platform.ignoredDevices.push(id)
254
+ }
255
+ break
256
+ case 'label':
257
+ if (typeof v !== 'string' || v === '') {
258
+ logIgnore(`${key}.${id}.${k}`)
259
+ } else {
260
+ platform.deviceConf[id][k] = v
261
+ }
262
+ break
263
+ default:
264
+ logRemove(`${key}.${id}.${k}`)
265
+ }
266
+ })
267
+ })
268
+ } else {
269
+ logIgnore(key)
270
+ }
271
+ break
272
+ case 'name':
273
+ case 'platform':
274
+ break
275
+ default:
276
+ logRemove(key)
277
+ break
278
+ }
279
+ })
280
+ }
@@ -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/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.15",
5
+ "version": "11.32.2-beta.16",
6
6
  "description": "Homebridge plugin to integrate Govee devices into HomeKit.",
7
7
  "author": {
8
8
  "name": "bwp91",