@homebridge-plugins/homebridge-ecovacs 7.0.0

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.
@@ -0,0 +1,1557 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createRequire } from 'node:module'
3
+ import process from 'node:process'
4
+
5
+ import { countries, EcoVacsAPI } from 'ecovacs-deebot'
6
+
7
+ import platformConsts from './utils/constants.js'
8
+ import platformChars from './utils/custom-chars.js'
9
+ import { parseError, sleep } from './utils/functions.js'
10
+ import platformLang from './utils/lang-en.js'
11
+
12
+ const require = createRequire(import.meta.url)
13
+ const plugin = require('../package.json')
14
+
15
+ const devicesInHB = new Map()
16
+
17
+ export default class {
18
+ constructor(log, config, api) {
19
+ if (!log || !api) {
20
+ return
21
+ }
22
+
23
+ // Begin plugin initialisation
24
+ try {
25
+ this.api = api
26
+ this.log = log
27
+ this.isBeta = plugin.version.includes('beta')
28
+
29
+ // Configuration objects for accessories
30
+ this.deviceConf = {}
31
+ this.ignoredDevices = []
32
+
33
+ // Make sure user is running Homebridge v1.6 or above
34
+ if (!api?.versionGreaterOrEqual('1.6.0')) {
35
+ throw new Error(platformLang.hbVersionFail)
36
+ }
37
+
38
+ // Check the user has configured the plugin
39
+ if (!config) {
40
+ throw new Error(platformLang.pluginNotConf)
41
+ }
42
+
43
+ // Log some environment info for debugging
44
+ this.log(
45
+ '%s v%s | System %s | Node %s | HB v%s | HAPNodeJS v%s...',
46
+ platformLang.initialising,
47
+ plugin.version,
48
+ process.platform,
49
+ process.version,
50
+ api.serverVersion,
51
+ api.hap.HAPLibraryVersion(),
52
+ )
53
+
54
+ // Check the user has entered the required config fields
55
+ if (!config.username || !config.password || !config.countryCode) {
56
+ throw new Error(platformLang.missingCreds)
57
+ }
58
+
59
+ // Apply the user's configuration
60
+ this.config = platformConsts.defaultConfig
61
+ this.applyUserConfig(config)
62
+
63
+ // Create further variables needed by the plugin
64
+ this.hapErr = api.hap.HapStatusError
65
+ this.hapChar = api.hap.Characteristic
66
+ this.hapServ = api.hap.Service
67
+
68
+ // Set up the Homebridge events
69
+ this.api.on('didFinishLaunching', () => this.pluginSetup())
70
+ this.api.on('shutdown', () => this.pluginShutdown())
71
+ } catch (err) {
72
+ // Catch any errors during initialisation
73
+ log.warn('***** %s. *****', platformLang.disabling)
74
+ log.warn('***** %s. *****', parseError(err, [
75
+ platformLang.hbVersionFail,
76
+ platformLang.pluginNotConf,
77
+ platformLang.missingCreds,
78
+ platformLang.invalidCCode,
79
+ platformLang.invalidPassword,
80
+ platformLang.invalidUsername,
81
+ ]))
82
+ }
83
+ }
84
+
85
+ applyUserConfig(config) {
86
+ // These shorthand functions save line space during config parsing
87
+ const logDefault = (k, def) => {
88
+ this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def)
89
+ }
90
+ const logDuplicate = (k) => {
91
+ this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup)
92
+ }
93
+ const logIgnore = (k) => {
94
+ this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn)
95
+ }
96
+ const logIgnoreItem = (k) => {
97
+ this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem)
98
+ }
99
+ const logIncrease = (k, min) => {
100
+ this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min)
101
+ }
102
+ const logQuotes = (k) => {
103
+ this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts)
104
+ }
105
+ const logRemove = (k) => {
106
+ this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv)
107
+ }
108
+
109
+ // Begin applying the user's config
110
+ Object.entries(config).forEach((entry) => {
111
+ const [key, val] = entry
112
+ switch (key) {
113
+ case 'countryCode':
114
+ if (typeof val !== 'string' || val === '') {
115
+ throw new Error(platformLang.invalidCCode)
116
+ }
117
+ this.config.countryCode = val.toUpperCase().replace(/[^A-Z]+/g, '')
118
+ if (!Object.keys(countries).includes(this.config.countryCode)) {
119
+ throw new Error(platformLang.invalidCCode)
120
+ }
121
+ break
122
+ case 'devices':
123
+ if (Array.isArray(val) && val.length > 0) {
124
+ val.forEach((x) => {
125
+ if (!x.deviceId) {
126
+ logIgnoreItem(key)
127
+ return
128
+ }
129
+ const id = x.deviceId.replace(/\s+/g, '')
130
+ if (Object.keys(this.deviceConf).includes(id)) {
131
+ logDuplicate(`${key}.${id}`)
132
+ return
133
+ }
134
+ const entries = Object.entries(x)
135
+ if (entries.length === 1) {
136
+ logRemove(`${key}.${id}`)
137
+ return
138
+ }
139
+ this.deviceConf[id] = platformConsts.defaultDevice
140
+ entries.forEach((subEntry) => {
141
+ const [k, v] = subEntry
142
+ switch (k) {
143
+ case 'areaNote1':
144
+ case 'areaNote2':
145
+ case 'areaNote3':
146
+ case 'areaNote4':
147
+ case 'areaNote5':
148
+ case 'areaNote6':
149
+ case 'areaNote7':
150
+ case 'areaNote8':
151
+ case 'areaNote9':
152
+ case 'areaNote10':
153
+ case 'areaNote11':
154
+ case 'areaNote12':
155
+ case 'areaNote13':
156
+ case 'areaNote14':
157
+ case 'areaNote15':
158
+ // Just ignore the command notes as they are intended for user information during configuration only.
159
+ break
160
+ case 'areaType1':
161
+ case 'areaType2':
162
+ case 'areaType3':
163
+ case 'areaType4':
164
+ case 'areaType5':
165
+ case 'areaType6':
166
+ case 'areaType7':
167
+ case 'areaType8':
168
+ case 'areaType9':
169
+ case 'areaType10':
170
+ case 'areaType11':
171
+ case 'areaType12':
172
+ case 'areaType13':
173
+ case 'areaType14':
174
+ case 'areaType15':
175
+ if (typeof v !== 'string' || v === '') {
176
+ logIgnore(`${key}.${id}.${k}`)
177
+ } else {
178
+ // Just take over the command type as it comes from a string enumeration.
179
+ this.deviceConf[id][k] = v
180
+ }
181
+ break
182
+ case 'customAreaCoordinates1':
183
+ case 'customAreaCoordinates2':
184
+ case 'customAreaCoordinates3':
185
+ case 'customAreaCoordinates4':
186
+ case 'customAreaCoordinates5':
187
+ case 'customAreaCoordinates6':
188
+ case 'customAreaCoordinates7':
189
+ case 'customAreaCoordinates8':
190
+ case 'customAreaCoordinates9':
191
+ case 'customAreaCoordinates10':
192
+ case 'customAreaCoordinates11':
193
+ case 'customAreaCoordinates12':
194
+ case 'customAreaCoordinates13':
195
+ case 'customAreaCoordinates14':
196
+ case 'customAreaCoordinates15': {
197
+ if (typeof v !== 'string' || v === '') {
198
+ logIgnore(`${key}.${id}.${k}`)
199
+ } else {
200
+ // Strip off everything else than signs, figures, periods and commas.
201
+ const stripped = v.replace(/[^-\d.,]+/g, '')
202
+ if (stripped) {
203
+ this.deviceConf[id][k] = stripped
204
+ } else {
205
+ logIgnore(`${key}.${id}.${k}`)
206
+ }
207
+ }
208
+ break
209
+ }
210
+ case 'deviceId':
211
+ case 'label':
212
+ break
213
+ case 'hideMotionSensor':
214
+ case 'showBattHumidity':
215
+ case 'showMotionLowBatt':
216
+ case 'supportTrueDetect':
217
+ if (typeof v === 'string') {
218
+ logQuotes(`${key}.${id}.${k}`)
219
+ }
220
+ this.deviceConf[id][k] = v === 'false' ? false : !!v
221
+ break
222
+ case 'ignoreDevice':
223
+ if (typeof v === 'string') {
224
+ logQuotes(`${key}.${id}.${k}`)
225
+ }
226
+ if (!!v && v !== 'false') {
227
+ this.ignoredDevices.push(id)
228
+ }
229
+ break
230
+ case 'lowBattThreshold':
231
+ case 'motionDuration': {
232
+ if (typeof v === 'string') {
233
+ logQuotes(`${key}.${id}.${k}`)
234
+ }
235
+ const intVal = Number.parseInt(v, 10)
236
+ if (Number.isNaN(intVal)) {
237
+ logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
238
+ this.deviceConf[id][k] = platformConsts.defaultValues[k]
239
+ } else if (intVal < platformConsts.minValues[k]) {
240
+ logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k])
241
+ this.deviceConf[id][k] = platformConsts.minValues[k]
242
+ } else {
243
+ this.deviceConf[id][k] = intVal
244
+ }
245
+ break
246
+ }
247
+ case 'pollInterval': {
248
+ if (typeof v === 'string') {
249
+ logQuotes(`${key}.${id}.${k}`)
250
+ }
251
+ const intVal = Number.parseInt(v, 10)
252
+ if (Number.isNaN(intVal)) {
253
+ logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
254
+ this.deviceConf[id][k] = platformConsts.defaultValues[k]
255
+ } else if (intVal === 0) {
256
+ this.deviceConf[id][k] = intVal
257
+ } else if (intVal < platformConsts.minValues[k]) {
258
+ logIncrease(key, platformConsts.minValues[k])
259
+ this.deviceConf[id][k] = platformConsts.minValues[k]
260
+ } else {
261
+ this.deviceConf[id][k] = intVal
262
+ }
263
+ break
264
+ }
265
+ case 'showAirDryingSwitch': {
266
+ const inSet = platformConsts.allowed[k].includes(v)
267
+ if (typeof v !== 'string' || !inSet) {
268
+ logIgnore(`${key}.${id}.${k}`)
269
+ } else {
270
+ this.deviceConf[id][k] = inSet ? v : platformConsts.defaultValues[k]
271
+ }
272
+ break
273
+ }
274
+ case 'spotAreaIDs1':
275
+ case 'spotAreaIDs2':
276
+ case 'spotAreaIDs3':
277
+ case 'spotAreaIDs4':
278
+ case 'spotAreaIDs5':
279
+ case 'spotAreaIDs6':
280
+ case 'spotAreaIDs7':
281
+ case 'spotAreaIDs8':
282
+ case 'spotAreaIDs9':
283
+ case 'spotAreaIDs10':
284
+ case 'spotAreaIDs11':
285
+ case 'spotAreaIDs12':
286
+ case 'spotAreaIDs13':
287
+ case 'spotAreaIDs14':
288
+ case 'spotAreaIDs15': {
289
+ if (typeof v !== 'string' || v === '') {
290
+ logIgnore(`${key}.${id}.${k}`)
291
+ } else {
292
+ // Strip off everything else than figures and commas.
293
+ const stripped = v.replace(/[^\d,]+/g, '')
294
+ if (stripped) {
295
+ this.deviceConf[id][k] = stripped
296
+ } else {
297
+ logIgnore(`${key}.${id}.${k}`)
298
+ }
299
+ }
300
+ break
301
+ }
302
+ default:
303
+ logRemove(`${key}.${id}.${k}`)
304
+ }
305
+ })
306
+ })
307
+ } else {
308
+ logIgnore(key)
309
+ }
310
+ break
311
+ case 'disableDeviceLogging':
312
+ case 'useYeedi':
313
+ if (typeof val === 'string') {
314
+ logQuotes(key)
315
+ }
316
+ this.config[key] = val === 'false' ? false : !!val
317
+ break
318
+ case 'name':
319
+ case 'platform':
320
+ break
321
+ case 'password':
322
+ if (typeof val !== 'string' || val === '') {
323
+ throw new Error(platformLang.invalidPassword)
324
+ }
325
+ this.config.password = val
326
+ break
327
+ case 'username':
328
+ if (typeof val !== 'string' || val === '') {
329
+ throw new Error(platformLang.invalidUsername)
330
+ }
331
+ this.config.username = val.replace(/\s+/g, '')
332
+ break
333
+ default:
334
+ logRemove(key)
335
+ break
336
+ }
337
+ })
338
+ }
339
+
340
+ async pluginSetup() {
341
+ // Plugin has finished initialising so now onto setup
342
+ try {
343
+ // Log that the plugin initialisation has been successful
344
+ this.log('%s.', platformLang.initialised)
345
+
346
+ // Sort out some logging functions
347
+ if (this.isBeta) {
348
+ this.log.debug = this.log
349
+ this.log.debugWarn = this.log.warn
350
+
351
+ // Log that using a beta will generate a lot of debug logs
352
+ if (this.isBeta) {
353
+ const divide = '*'.repeat(platformLang.beta.length + 1) // don't forget the full stop (+1!)
354
+ this.log.warn(divide)
355
+ this.log.warn(`${platformLang.beta}.`)
356
+ this.log.warn(divide)
357
+ }
358
+ } else {
359
+ this.log.debug = () => {}
360
+ this.log.debugWarn = () => {}
361
+ }
362
+
363
+ // Require any libraries that the accessory instances use
364
+ this.cusChar = new platformChars(this.api)
365
+
366
+ // Connect to ECOVACS/Yeedi
367
+ this.ecovacsAPI = new EcoVacsAPI(
368
+ EcoVacsAPI.getDeviceId(this.api.hap.uuid.generate(this.config.username)),
369
+ this.config.countryCode,
370
+ countries[this.config.countryCode].continent,
371
+ this.config.useYeedi ? 'yeedi.com' : 'ecovacs.com',
372
+ )
373
+
374
+ // Display version of the ecovacs-deebot library in the log
375
+ this.log('%s v%s.', platformLang.ecovacsLibVersion, this.ecovacsAPI.getVersion())
376
+
377
+ // Attempt to log in to ECOVACS/Yeedi
378
+ try {
379
+ await this.ecovacsAPI.connect(this.config.username, EcoVacsAPI.md5(this.config.password))
380
+ } catch (err) {
381
+ // Check if password error and reattempt with base64 decoded version of password
382
+ if (err.message?.includes('1010')) {
383
+ this.config.password = Buffer.from(this.config.password, 'base64')
384
+ .toString('utf8')
385
+ .replace(/\r\n|\n|\r/g, '')
386
+ .trim()
387
+ await this.ecovacsAPI.connect(
388
+ this.config.username,
389
+ EcoVacsAPI.md5(this.config.password),
390
+ )
391
+ } else {
392
+ throw err
393
+ }
394
+ }
395
+
396
+ // Get a device list from ECOVACS/Yeedi
397
+ const deviceList = await this.ecovacsAPI.devices()
398
+
399
+ // Check the request for device list was successful
400
+ if (!Array.isArray(deviceList)) {
401
+ throw new TypeError(platformLang.deviceListFail)
402
+ }
403
+
404
+ // Initialise each device into Homebridge
405
+ this.log('[%s] %s.', deviceList.length, platformLang.deviceCount(this.config.useYeedi ? 'Yeedi' : 'ECOVACS'))
406
+ for (let i = 0; i < deviceList.length; i += 1) {
407
+ await this.initialiseDevice(deviceList[i])
408
+ }
409
+
410
+ // Start the polling intervals for device state refresh, each device may have a different refresh time
411
+ // We add a refresh interval per device later in initialiseDevice()
412
+ this.refreshIntervals = {}
413
+
414
+ // Setup successful
415
+ this.log('%s. %s', platformLang.complete, platformLang.welcome)
416
+ } catch (err) {
417
+ // Catch any errors during setup
418
+ this.log.warn('***** %s. *****', platformLang.disabling)
419
+ this.log.warn('***** %s. *****', parseError(err, [platformLang.deviceListFail]))
420
+ this.pluginShutdown()
421
+ }
422
+ }
423
+
424
+ pluginShutdown() {
425
+ // A function that is called when the plugin fails to load or Homebridge restarts
426
+ try {
427
+ // Stop the refresh intervals
428
+ Object.keys(this.refreshIntervals).forEach((id) => {
429
+ clearInterval(this.refreshIntervals[id])
430
+ })
431
+
432
+ // Disconnect from each ECOVACS/Yeedi device
433
+ devicesInHB.forEach((accessory) => {
434
+ if (accessory.control?.is_ready) {
435
+ accessory.control.disconnect()
436
+ }
437
+ })
438
+ } catch (err) {
439
+ // No need to show errors at this point
440
+ }
441
+ }
442
+
443
+ initialiseDevice(device) {
444
+ try {
445
+ // Generate the Homebridge UUID from the device id
446
+ const uuid = this.api.hap.uuid.generate(device.did)
447
+
448
+ // If the accessory is in the ignored devices list then remove it
449
+ if (this.ignoredDevices.includes(device.did)) {
450
+ if (devicesInHB.has(uuid)) {
451
+ this.removeAccessory(devicesInHB.get(uuid))
452
+ }
453
+ return
454
+ }
455
+
456
+ // Load the device control information from ECOVACS/Yeedi
457
+ const loadedDevice = this.ecovacsAPI.getVacBot(
458
+ this.ecovacsAPI.uid,
459
+ EcoVacsAPI.REALM,
460
+ this.ecovacsAPI.resource,
461
+ this.ecovacsAPI.user_access_token,
462
+ device,
463
+ countries[this.config.countryCode].continent,
464
+ )
465
+
466
+ // Get the cached accessory or add to Homebridge if it doesn't exist
467
+ const accessory = devicesInHB.get(uuid) || this.addAccessory(loadedDevice)
468
+
469
+ accessory.context.rawConfig = this.deviceConf?.[device.did] || platformConsts.defaultDevice
470
+
471
+ // Final check the accessory now exists in Homebridge
472
+ if (!accessory) {
473
+ throw new Error(platformLang.accNotFound)
474
+ }
475
+
476
+ // Sort out some logging functions per accessory
477
+ if (this.isBeta) {
478
+ accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
479
+ accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
480
+ accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
481
+ accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
482
+ } else {
483
+ if (this.config.disableDeviceLogging) {
484
+ accessory.log = () => {}
485
+ accessory.logWarn = () => {}
486
+ } else {
487
+ accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
488
+ accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
489
+ }
490
+ accessory.logDebug = () => {}
491
+ accessory.logDebugWarn = () => {}
492
+ }
493
+
494
+ // Initially set the device online value to false (to be updated later)
495
+ accessory.context.isOnline = false
496
+ accessory.context.lastMsg = ''
497
+
498
+ // Add the 'clean' switch service if it doesn't already exist
499
+ const cleanService = accessory.getService('Clean') || accessory.addService(this.hapServ.Switch, 'Clean', 'clean')
500
+ if (!cleanService.testCharacteristic(this.hapChar.ConfiguredName)) {
501
+ cleanService.addCharacteristic(this.hapChar.ConfiguredName)
502
+ cleanService.updateCharacteristic(this.hapChar.ConfiguredName, 'Clean')
503
+ }
504
+ if (!cleanService.testCharacteristic(this.hapChar.ServiceLabelIndex)) {
505
+ cleanService.addCharacteristic(this.hapChar.ServiceLabelIndex)
506
+ cleanService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 1)
507
+ }
508
+
509
+ // Add the 'charge' switch service if it doesn't already exist
510
+ const chargeService = accessory.getService('Go Charge') || accessory.addService(this.hapServ.Switch, 'Go Charge', 'gocharge')
511
+ if (!chargeService.testCharacteristic(this.hapChar.ConfiguredName)) {
512
+ chargeService.addCharacteristic(this.hapChar.ConfiguredName)
513
+ chargeService.updateCharacteristic(this.hapChar.ConfiguredName, 'Go Charge')
514
+ }
515
+ if (!chargeService.testCharacteristic(this.hapChar.ServiceLabelIndex)) {
516
+ chargeService.addCharacteristic(this.hapChar.ServiceLabelIndex)
517
+ chargeService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 2)
518
+ }
519
+
520
+ // Check if the speed characteristic has been added
521
+ if (!cleanService.testCharacteristic(this.cusChar.MaxSpeed)) {
522
+ cleanService.addCharacteristic(this.cusChar.MaxSpeed)
523
+ }
524
+
525
+ // Add the Eve characteristic for custom commands if any exist
526
+ if (accessory.context.rawConfig.areaType1) {
527
+ if (!cleanService.testCharacteristic(this.cusChar.PredefinedArea)) {
528
+ cleanService.addCharacteristic(this.cusChar.PredefinedArea)
529
+ }
530
+
531
+ // Add the set characteristic
532
+ cleanService
533
+ .getCharacteristic(this.cusChar.PredefinedArea)
534
+ .onSet(async value => this.internalPredefinedAreaUpdate(accessory, value))
535
+ } else if (cleanService.testCharacteristic(this.cusChar.PredefinedArea)) {
536
+ cleanService.removeCharacteristic(cleanService.getCharacteristic(this.cusChar.PredefinedArea))
537
+ }
538
+
539
+ // Add the set handler to the 'clean' switch on/off characteristic
540
+ cleanService
541
+ .getCharacteristic(this.hapChar.On)
542
+ .updateValue(accessory.context.cacheClean === 'auto')
543
+ .removeOnSet()
544
+ .onSet(async value => this.internalCleanUpdate(accessory, value))
545
+
546
+ // Add the set handler to the 'max speed' switch on/off characteristic
547
+ cleanService.getCharacteristic(this.cusChar.MaxSpeed)
548
+ .onSet(async value => this.internalSpeedUpdate(accessory, value))
549
+
550
+ // Add the set handler to the 'charge' switch on/off characteristic
551
+ chargeService
552
+ .getCharacteristic(this.hapChar.On)
553
+ .updateValue(accessory.context.cacheCharge === 'charging')
554
+ .removeOnSet()
555
+ .onSet(async value => this.internalChargeUpdate(accessory, value))
556
+
557
+ // Add the 'attention' motion service if it doesn't already exist
558
+ if (!accessory.getService('Attention') && !accessory.context.rawConfig.hideMotionSensor) {
559
+ accessory.addService(this.hapServ.MotionSensor, 'Attention', 'attention')
560
+ }
561
+
562
+ // Remove the 'attention' motion service if it exists and user doesn't want it
563
+ if (accessory.getService('Attention') && accessory.context.rawConfig.hideMotionSensor) {
564
+ accessory.removeService(accessory.getService('Attention'))
565
+ }
566
+
567
+ // Set the motion sensor off if exists when the plugin initially loads
568
+ if (accessory.getService('Attention')) {
569
+ accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, false)
570
+ }
571
+
572
+ // Add the battery service if it doesn't already exist
573
+ if (!accessory.getService(this.hapServ.Battery)) {
574
+ accessory.addService(this.hapServ.Battery)
575
+ }
576
+
577
+ // Add the 'battery' humidity service if it doesn't already exist and user wants it
578
+ if (!accessory.getService('Battery Level') && accessory.context.rawConfig.showBattHumidity) {
579
+ accessory.addService(this.hapServ.HumiditySensor, 'Battery Level', 'batterylevel')
580
+ }
581
+
582
+ // Remove the 'battery' humidity service if it exists and user doesn't want it
583
+ if (accessory.getService('Battery Level') && !accessory.context.rawConfig.showBattHumidity) {
584
+ accessory.removeService(accessory.getService('Battery Level'))
585
+ }
586
+
587
+ // Add or remove the 'air drying' switch service according to the configuration (if it doesn't already exist) and add the set handler to the 'air drying' switch on/off characteristic
588
+ if (
589
+ accessory.context.rawConfig.showAirDryingSwitch === 'yes'
590
+ || (accessory.context.rawConfig.showAirDryingSwitch === 'presetting' && loadedDevice.hasAirDrying())
591
+ ) {
592
+ const dryingService = accessory.getService('Air Drying') || accessory.addService(this.hapServ.Switch, 'Air Drying', 'airdrying')
593
+ if (!dryingService.testCharacteristic(this.hapChar.ConfiguredName)) {
594
+ dryingService.addCharacteristic(this.hapChar.ConfiguredName)
595
+ dryingService.updateCharacteristic(this.hapChar.ConfiguredName, 'Air Drying')
596
+ }
597
+ if (!dryingService.testCharacteristic(this.hapChar.ServiceLabelIndex)) {
598
+ dryingService.addCharacteristic(this.hapChar.ServiceLabelIndex)
599
+ dryingService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 3)
600
+ }
601
+
602
+ dryingService
603
+ .getCharacteristic(this.hapChar.On)
604
+ .updateValue(accessory.context.cacheAirDrying === 'airdrying')
605
+ .removeOnSet()
606
+ .onSet(async value => this.internalAirDryingUpdate(accessory, value))
607
+ } else if (accessory.getService('Air Drying')) {
608
+ accessory.removeService(accessory.getService('Air Drying'))
609
+ accessory.logDebug('air drying service removed')
610
+ } else {
611
+ accessory.logDebug('no air drying available or not configured')
612
+ }
613
+
614
+ // TrueDetect service
615
+ if (accessory.context.rawConfig.supportTrueDetect) {
616
+ // Custom Eve characteristic like MaxSpeed
617
+ if (!cleanService.testCharacteristic(this.cusChar.TrueDetect)) {
618
+ cleanService.addCharacteristic(this.cusChar.TrueDetect)
619
+ }
620
+
621
+ // Add the set handler to the 'true detect' switch on/off characteristic
622
+ cleanService.getCharacteristic(this.cusChar.TrueDetect)
623
+ .onSet(async value => this.internalTrueDetectUpdate(accessory, value))
624
+ } else if (accessory.getService('TrueDetect')) {
625
+ // Remove TrueDetect service if exists
626
+ accessory.removeService(accessory.getService('TrueDetect'))
627
+ }
628
+
629
+ // Save the device control information to the accessory
630
+ accessory.control = loadedDevice
631
+
632
+ // Some models can use a V2 for supported commands
633
+ accessory.context.commandSuffix = accessory.control.is950type_V2()
634
+ ? '_V2'
635
+ : ''
636
+
637
+ // Set up a listener for the device 'ready' event
638
+ accessory.control.on('ready', (event) => {
639
+ if (event) {
640
+ this.externalReadyUpdate(accessory)
641
+ }
642
+ })
643
+
644
+ // Set up a listener for the device 'CleanReport' event
645
+ accessory.control.on('CleanReport', (newVal) => {
646
+ this.externalCleanUpdate(accessory, newVal)
647
+ })
648
+
649
+ // Set up a listener for the device 'CurrentCustomAreaValues' event
650
+ accessory.control.on('CurrentCustomAreaValues', (newVal) => {
651
+ accessory.logDebug(`CurrentCustomAreaValues: ${JSON.stringify(newVal)}`)
652
+ })
653
+
654
+ // Set up a listener for the device 'CleanSpeed' event
655
+ accessory.control.on('CleanSpeed', (newVal) => {
656
+ this.externalSpeedUpdate(accessory, newVal)
657
+ })
658
+
659
+ // Set up a listener for the device 'BatteryInfo' event
660
+ accessory.control.on('BatteryInfo', async (newVal) => {
661
+ await this.externalBatteryUpdate(accessory, newVal)
662
+ })
663
+
664
+ // Set up a listener for the device 'AirDryingState' event
665
+ // Only if the service exists
666
+ if (accessory.getService('Air Drying')) {
667
+ accessory.control.on('AirDryingState', (newVal) => {
668
+ this.externalAirDryingUpdate(accessory, newVal)
669
+ })
670
+ }
671
+
672
+ // Set up a listener for the device 'ChargeState' event
673
+ accessory.control.on('ChargeState', (newVal) => {
674
+ this.externalChargeUpdate(accessory, newVal)
675
+ })
676
+
677
+ // Set up a listener for the device 'NetInfoIP' event
678
+ accessory.control.on('NetInfoIP', (newVal) => {
679
+ this.externalIPUpdate(accessory, newVal)
680
+ })
681
+
682
+ // Set up a listener for the device 'NetInfoMAC' event
683
+ accessory.control.on('NetInfoMAC', (newVal) => {
684
+ this.externalMacUpdate(accessory, newVal)
685
+ })
686
+
687
+ if (accessory.context.rawConfig.supportTrueDetect) {
688
+ // Set up a listener for the device 'TrueDetect' event
689
+ accessory.control.on('TrueDetect', (newVal) => {
690
+ this.externalTrueDetectUpdate(accessory, newVal)
691
+ })
692
+ }
693
+
694
+ // Set up a listener for the device 'message' event
695
+ accessory.control.on('message', async (msg) => {
696
+ await this.externalMessageUpdate(accessory, msg)
697
+ })
698
+
699
+ // Set up a listener for the device 'Error' event
700
+ accessory.control.on('Error', async (err) => {
701
+ if (err) {
702
+ await this.externalErrorUpdate(accessory, err)
703
+ }
704
+ })
705
+
706
+ // Set up listeners for map data if accessory debug logging is on
707
+ accessory.control.on('Maps', (maps) => {
708
+ if (maps) {
709
+ accessory.logDebug(`Maps: ${JSON.stringify(maps)}`)
710
+ Object.keys(maps.maps).forEach((key) => {
711
+ accessory.control.run('GetSpotAreas', maps.maps[key].mapID)
712
+ accessory.control.run('GetVirtualBoundaries', maps.maps[key].mapID)
713
+ })
714
+ }
715
+ })
716
+
717
+ accessory.control.on('MapSpotAreas', (spotAreas) => {
718
+ if (spotAreas) {
719
+ accessory.logDebug(`MapSpotAreas: ${JSON.stringify(spotAreas)}`)
720
+ Object.keys(spotAreas.mapSpotAreas).forEach((key) => {
721
+ accessory.control.run(
722
+ 'GetSpotAreaInfo',
723
+ spotAreas.mapID,
724
+ spotAreas.mapSpotAreas[key].mapSpotAreaID,
725
+ )
726
+ })
727
+ }
728
+ })
729
+
730
+ accessory.control.on('MapSpotAreaInfo', (area) => {
731
+ if (area) {
732
+ accessory.logDebug(`MapSpotAreaInfo: ${JSON.stringify(area)}`)
733
+ }
734
+ })
735
+
736
+ accessory.control.on('MapVirtualBoundaries', (vbs) => {
737
+ if (vbs) {
738
+ accessory.logDebug(`MapVirtualBoundaries: ${JSON.stringify(vbs)}`)
739
+ const vbsCombined = [...vbs.mapVirtualWalls, ...vbs.mapNoMopZones]
740
+ const virtualBoundaryArray = []
741
+ Object.keys(vbsCombined).forEach((key) => {
742
+ virtualBoundaryArray[vbsCombined[key].mapVirtualBoundaryID] = vbsCombined[key]
743
+ })
744
+ Object.keys(virtualBoundaryArray).forEach((key) => {
745
+ accessory.control.run(
746
+ 'GetVirtualBoundaryInfo',
747
+ vbs.mapID,
748
+ virtualBoundaryArray[key].mapVirtualBoundaryID,
749
+ virtualBoundaryArray[key].mapVirtualBoundaryType,
750
+ )
751
+ })
752
+ }
753
+ })
754
+
755
+ accessory.control.on('MapVirtualBoundaryInfo', (vb) => {
756
+ if (vb) {
757
+ accessory.logDebug(`MapVirtualBoundaryInfo: ${JSON.stringify(vb)}`)
758
+ }
759
+ })
760
+
761
+ // Connect to the device
762
+ accessory.control.connect()
763
+
764
+ // Refresh the current state of all the accessories
765
+ this.refreshAccessory(accessory)
766
+ const { pollInterval } = accessory.context.rawConfig[device] || platformConsts.defaultValues
767
+ if (pollInterval > 0) {
768
+ this.refreshIntervals[device.did] = setInterval(() => {
769
+ devicesInHB.get(this.api.hap.uuid.generate(device.did)).control?.refresh()
770
+ }, pollInterval * 1000)
771
+ }
772
+
773
+ // Update any changes to the accessory to the platform
774
+ this.api.updatePlatformAccessories([accessory])
775
+ devicesInHB.set(accessory.UUID, accessory)
776
+
777
+ // Log configuration and device initialisation
778
+ this.log(
779
+ '[%s] %s: %s.',
780
+ accessory.displayName,
781
+ platformLang.devInitOpts,
782
+ JSON.stringify(accessory.context.rawConfig),
783
+ )
784
+ this.log(
785
+ '[%s] %s [%s] %s %s.',
786
+ accessory.displayName,
787
+ platformLang.devInit,
788
+ device.did,
789
+ platformLang.addInfo,
790
+ JSON.stringify(device),
791
+ )
792
+
793
+ // If after five seconds the device hasn't responded then mark as offline
794
+ setTimeout(() => {
795
+ if (!accessory.context.isOnline) {
796
+ accessory.logWarn(platformLang.repOffline)
797
+ }
798
+ }, 5000)
799
+ } catch (err) {
800
+ const dName = device.nick || device.did
801
+ this.log.warn('[%s] %s %s.', dName, platformLang.devNotInit, parseError(err, [platformLang.accNotFound]))
802
+ this.log.warn(err)
803
+ }
804
+ }
805
+
806
+ refreshAccessory(accessory) {
807
+ try {
808
+ // Check the device has initialised already
809
+ if (!accessory.control) {
810
+ return
811
+ }
812
+
813
+ // Set up a flag to check later if we have had a response
814
+ accessory.context.hadResponse = false
815
+
816
+ // Run the commands to get the state of the device
817
+ accessory.logDebug(`${platformLang.sendCmd} [GetBatteryState]`)
818
+ accessory.control.run('GetBatteryState')
819
+
820
+ accessory.logDebug(`${platformLang.sendCmd} [GetChargeState]`)
821
+ accessory.control.run('GetChargeState')
822
+
823
+ if (accessory.getService('Air Drying')) {
824
+ accessory.logDebug(`${platformLang.sendCmd} [GetAirDrying]`)
825
+ accessory.control.run('GetAirDrying')
826
+ }
827
+
828
+ accessory.logDebug(`${platformLang.sendCmd} [GetCleanState${accessory.context.commandSuffix}]`)
829
+ accessory.control.run(`GetCleanState${accessory.context.commandSuffix}`)
830
+
831
+ accessory.logDebug(`${platformLang.sendCmd} [GetCleanSpeed]`)
832
+ accessory.control.run('GetCleanSpeed')
833
+
834
+ accessory.logDebug(`${platformLang.sendCmd} [GetNetInfo]`)
835
+ accessory.control.run('GetNetInfo')
836
+
837
+ // TrueDetect if the accessory supports it
838
+ if (accessory.context.rawConfig.supportTrueDetect) {
839
+ accessory.logDebug(`${platformLang.sendCmd} [GetTrueDetect]`)
840
+ accessory.control.run('GetTrueDetect')
841
+ }
842
+
843
+ setTimeout(() => {
844
+ if (!accessory.context.isOnline && accessory.context.hadResponse) {
845
+ accessory.logDebug(platformLang.repOnline)
846
+ accessory.context.isOnline = true
847
+ this.api.updatePlatformAccessories([accessory])
848
+ devicesInHB.set(accessory.UUID, accessory)
849
+ }
850
+ if (accessory.context.isOnline && !accessory.context.hadResponse) {
851
+ accessory.logDebug(platformLang.repOffline)
852
+ accessory.context.isOnline = false
853
+ this.api.updatePlatformAccessories([accessory])
854
+ devicesInHB.set(accessory.UUID, accessory)
855
+ }
856
+ }, 5000)
857
+ } catch (err) {
858
+ // Catch any errors in the refresh process
859
+ accessory.logWarn(`${platformLang.devNotRef} ${parseError(err)}`)
860
+ }
861
+ }
862
+
863
+ addAccessory(device) {
864
+ // Add an accessory to Homebridge
865
+ let displayName = 'Unknown'
866
+ try {
867
+ displayName = device.vacuum.nick || device.vacuum.did
868
+ const accessory = new this.api.platformAccessory(
869
+ displayName,
870
+ this.api.hap.uuid.generate(device.vacuum.did),
871
+ )
872
+ accessory
873
+ .getService(this.hapServ.AccessoryInformation)
874
+ .setCharacteristic(this.hapChar.Name, displayName)
875
+ .setCharacteristic(this.hapChar.ConfiguredName, displayName)
876
+ .setCharacteristic(this.hapChar.SerialNumber, device.vacuum.did)
877
+ .setCharacteristic(this.hapChar.Manufacturer, device.vacuum.company)
878
+ .setCharacteristic(this.hapChar.Model, device.deviceModel)
879
+ .setCharacteristic(this.hapChar.Identify, true)
880
+
881
+ // Add context information for Homebridge plugin-ui
882
+ accessory.context.ecoDeviceId = device.vacuum.did
883
+ accessory.context.ecoCompany = device.vacuum.company
884
+ accessory.context.ecoModel = device.deviceModel
885
+ accessory.context.ecoClass = device.vacuum.class
886
+ accessory.context.ecoResource = device.vacuum.resource
887
+ accessory.context.ecoImage = device.deviceImageURL
888
+ this.api.registerPlatformAccessories(plugin.name, plugin.alias, [accessory])
889
+ devicesInHB.set(accessory.UUID, accessory)
890
+ this.log('[%s] %s.', displayName, platformLang.devAdd)
891
+ return accessory
892
+ } catch (err) {
893
+ // Catch any errors during add
894
+ this.log.warn('[%s] %s %s.', displayName, platformLang.devNotAdd, parseError(err))
895
+ return false
896
+ }
897
+ }
898
+
899
+ configureAccessory(accessory) {
900
+ // Add the configured accessory to our global map
901
+ devicesInHB.set(accessory.UUID, accessory)
902
+ accessory
903
+ .getService('Clean')
904
+ .getCharacteristic(this.api.hap.Characteristic.On)
905
+ .onSet(() => {
906
+ this.log.warn('[%s] %s.', accessory.displayName, platformLang.accNotReady)
907
+ throw new this.api.hap.HapStatusError(-70402)
908
+ })
909
+ .updateValue(new this.api.hap.HapStatusError(-70402))
910
+ accessory
911
+ .getService('Go Charge')
912
+ .getCharacteristic(this.api.hap.Characteristic.On)
913
+ .onSet(() => {
914
+ this.log.warn('[%s] %s.', accessory.displayName, platformLang.accNotReady)
915
+ throw new this.api.hap.HapStatusError(-70402)
916
+ })
917
+ .updateValue(new this.api.hap.HapStatusError(-70402))
918
+ }
919
+
920
+ removeAccessory(accessory) {
921
+ // Remove an accessory from Homebridge
922
+ try {
923
+ this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory])
924
+ devicesInHB.delete(accessory.UUID)
925
+ this.log('[%s] %s.', accessory.displayName, platformLang.devRemove)
926
+ } catch (err) {
927
+ // Catch any errors during remove
928
+ this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotRemove, parseError(err))
929
+ }
930
+ }
931
+
932
+ async internalCleanUpdate(accessory, value) {
933
+ try {
934
+ // Don't continue if we can't send commands to the device
935
+ if (!accessory.control) {
936
+ throw new Error(platformLang.errNotInit)
937
+ }
938
+ if (!accessory.control.is_ready) {
939
+ throw new Error(platformLang.errNotReady)
940
+ }
941
+
942
+ // A one-second delay seems to make turning off the 'charge' switch more responsive
943
+ await sleep(1)
944
+
945
+ // Turn the 'charge' switch off since we have commanded the 'clean' switch
946
+ accessory.getService('Go Charge').updateCharacteristic(this.hapChar.On, false)
947
+
948
+ // Select the correct command to run, either start or stop cleaning
949
+ const order = value ? `Clean${accessory.context.commandSuffix}` : 'Stop'
950
+
951
+ // Log the update
952
+ accessory.log(`${platformLang.curCleaning} [${value ? platformLang.cleaning : platformLang.stop}}]`)
953
+
954
+ // Send the command
955
+ accessory.logDebug(`${platformLang.sendCmd} [${order}]`)
956
+ accessory.control.run(order)
957
+ } catch (err) {
958
+ // Catch any errors during the process
959
+ accessory.logWarn(`${platformLang.cleanFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
960
+
961
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
962
+ setTimeout(() => {
963
+ accessory
964
+ .getService('Clean')
965
+ .updateCharacteristic(this.hapChar.On, accessory.context.cacheClean === 'auto')
966
+ }, 2000)
967
+ throw new this.hapErr(-70402)
968
+ }
969
+ }
970
+
971
+ async internalSpeedUpdate(accessory, value) {
972
+ try {
973
+ // Don't continue if we can't send commands to the device
974
+ if (!accessory.control) {
975
+ throw new Error(platformLang.errNotInit)
976
+ }
977
+ if (!accessory.control.is_ready) {
978
+ throw new Error(platformLang.errNotReady)
979
+ }
980
+
981
+ // Set speed to max (3) if value is true otherwise set to standard (2)
982
+ const command = value ? 3 : 2
983
+
984
+ // Log the update
985
+ accessory.log(`${platformLang.curSpeed} [${platformConsts.speed2Label[command]}]`)
986
+
987
+ // Send the command
988
+ accessory.logDebug(`${platformLang.sendCmd} [SetCleanSpeed: ${command}]`)
989
+ accessory.control.run('SetCleanSpeed', command)
990
+ } catch (err) {
991
+ // Catch any errors during the process
992
+ accessory.logWarn(`${platformLang.speedFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
993
+
994
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
995
+ setTimeout(() => {
996
+ accessory
997
+ .getService('Clean')
998
+ .updateCharacteristic(this.cusChar.MaxSpeed, [3, 4].includes(accessory.context.cacheSpeed))
999
+ }, 2000)
1000
+ throw new this.hapErr(-70402)
1001
+ }
1002
+ }
1003
+
1004
+ async internalPredefinedAreaUpdate(accessory, value) {
1005
+ try {
1006
+ // Don't continue if we can't send commands to the device
1007
+ if (!accessory.control) {
1008
+ throw new Error(platformLang.errNotInit)
1009
+ }
1010
+ if (!accessory.control.is_ready) {
1011
+ throw new Error(platformLang.errNotReady)
1012
+ }
1013
+
1014
+ // Eve app for some reason still sends values with decimal places
1015
+ value = Math.round(value)
1016
+
1017
+ // A value of 0 doesn't do anything
1018
+ if (value === 0) {
1019
+ accessory.logDebugWarn(platformLang.returningAsValueNull)
1020
+ return
1021
+ }
1022
+
1023
+ // Avoid quick switching with this function
1024
+ const updateKey = Math.random()
1025
+ .toString(36)
1026
+ .substr(2, 8)
1027
+ accessory.context.lastCommandKey = updateKey
1028
+ await sleep(1)
1029
+ if (updateKey !== accessory.context.lastCommandKey) {
1030
+ accessory.logWarn(platformLang.skippingValue)
1031
+ return
1032
+ }
1033
+
1034
+ // Obtain the area type from the device config
1035
+ const areaType = accessory.context.rawConfig[`areaType${value}`]
1036
+
1037
+ // Don't continue if no command type for this number has been configured
1038
+ if (!areaType) {
1039
+ throw new Error(`${platformLang.noTypeForArea}: ${value}`)
1040
+ }
1041
+
1042
+ accessory.log(`${platformLang.typeForArea} ${value}: ${areaType}`)
1043
+
1044
+ // Obtain the command from the device config
1045
+ const command = accessory.context.rawConfig[areaType === 'spotArea'
1046
+ ? `spotAreaIDs${value}`
1047
+ : `customAreaCoordinates${value}`]
1048
+
1049
+ // Don't continue if no command for this number has been configured
1050
+ if (!command) {
1051
+ throw new Error(`${platformLang.noCommandForArea}: ${value}`)
1052
+ }
1053
+
1054
+ accessory.log(`${platformLang.commandForArea} ${value}: ${command}`)
1055
+
1056
+ // Send the command
1057
+ switch (areaType) {
1058
+ case 'spotArea':
1059
+ accessory.logDebug(`${platformLang.sendCmd} [SpotArea${accessory.context.commandSuffix}: ${command}]`)
1060
+
1061
+ if (accessory.context.commandSuffix === '_V2') {
1062
+ accessory.control.run('SpotArea_V2', command)
1063
+ } else {
1064
+ accessory.control.run('SpotArea', 'start', command)
1065
+ }
1066
+ break
1067
+
1068
+ case 'customArea':
1069
+ accessory.logDebug(`${platformLang.sendCmd} [CustomArea${accessory.context.commandSuffix}: ${command}]`)
1070
+
1071
+ if (accessory.context.commandSuffix === '_V2') {
1072
+ accessory.control.run('CustomArea_V2', command)
1073
+ } else {
1074
+ accessory.control.run('CustomArea', 'start', command)
1075
+ }
1076
+ break
1077
+
1078
+ default:
1079
+ throw new Error(`${areaType}: ${platformLang.unknownCommandTypeForArea}`)
1080
+ }
1081
+
1082
+ accessory.log(platformLang.commandSent)
1083
+
1084
+ // Set the value back to 0 after two seconds and turn the main ON switch on
1085
+ setTimeout(() => {
1086
+ accessory.getService('Clean').updateCharacteristic(this.cusChar.PredefinedArea, 0)
1087
+ accessory.getService('Clean').updateCharacteristic(this.hapChar.On, true)
1088
+ accessory.log(platformLang.characteristicsReset)
1089
+ }, 2000)
1090
+ } catch (err) {
1091
+ // Catch any errors during the process
1092
+ accessory.logWarn(`${platformLang.speedFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
1093
+
1094
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
1095
+ setTimeout(() => {
1096
+ accessory.getService('Clean').updateCharacteristic(this.cusChar.PredefinedArea, 0)
1097
+ }, 2000)
1098
+ throw new this.hapErr(-70402)
1099
+ }
1100
+ }
1101
+
1102
+ async internalChargeUpdate(accessory, value) {
1103
+ try {
1104
+ // Don't continue if we can't send commands to the device
1105
+ if (!accessory.control) {
1106
+ throw new Error(platformLang.errNotInit)
1107
+ }
1108
+ if (!accessory.control.is_ready) {
1109
+ throw new Error(platformLang.errNotReady)
1110
+ }
1111
+
1112
+ // A one-second delay seems to make everything more responsive
1113
+ await sleep(1)
1114
+
1115
+ // Don't continue if the device is already charging
1116
+ const battService = accessory.getService(this.hapServ.Battery)
1117
+ if (battService.getCharacteristic(this.hapChar.ChargingState).value !== 0) {
1118
+ return
1119
+ }
1120
+
1121
+ // Select the correct command to run, either start or stop going to charge
1122
+ const order = value ? 'Charge' : 'Stop'
1123
+
1124
+ // Log the update
1125
+ accessory.log(`${platformLang.curCharging} [${value ? platformLang.returning : platformLang.stop}]`)
1126
+
1127
+ // Send the command
1128
+ accessory.logDebug(`${platformLang.sendCmd} [${order}]`)
1129
+ accessory.control.run(order)
1130
+ } catch (err) {
1131
+ // Catch any errors during the process
1132
+ accessory.logWarn(`${platformLang.chargeFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
1133
+
1134
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
1135
+ setTimeout(() => {
1136
+ accessory
1137
+ .getService('Go Charge')
1138
+ .updateCharacteristic(this.hapChar.On, accessory.context.cacheCharge === 'charging')
1139
+ }, 2000)
1140
+ throw new this.hapErr(-70402)
1141
+ }
1142
+ }
1143
+
1144
+ async internalAirDryingUpdate(accessory, value) {
1145
+ try {
1146
+ // Don't continue if we can't send commands to the device
1147
+ if (!accessory.control) {
1148
+ throw new Error(platformLang.errNotInit)
1149
+ }
1150
+ if (!accessory.control.is_ready) {
1151
+ throw new Error(platformLang.errNotReady)
1152
+ }
1153
+
1154
+ // A one-second delay seems to make everything more responsive
1155
+ await sleep(1)
1156
+
1157
+ // Select the correct command to run, either start or stop air drying.
1158
+ const order = value ? 'AirDryingStart' : 'AirDryingStop'
1159
+
1160
+ // Send the command
1161
+ accessory.logDebug(`${platformLang.sendCmd} [${order}]`)
1162
+ accessory.control.run(order)
1163
+ } catch (err) {
1164
+ // Catch any errors during the process
1165
+ accessory.logWarn(`${platformLang.airDryingFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
1166
+
1167
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
1168
+ setTimeout(() => {
1169
+ accessory
1170
+ .getService('Air Drying')
1171
+ .updateCharacteristic(this.hapChar.On, accessory.context.cacheAirDrying === 'airdrying')
1172
+ }, 2000)
1173
+ throw new this.hapErr(-70402)
1174
+ }
1175
+ }
1176
+
1177
+ async internalTrueDetectUpdate(accessory, value) {
1178
+ try {
1179
+ // Don't continue if we can't send commands to the device
1180
+ if (!accessory.control) {
1181
+ throw new Error(platformLang.errNotInit)
1182
+ }
1183
+ if (!accessory.control.is_ready) {
1184
+ throw new Error(platformLang.errNotReady)
1185
+ }
1186
+
1187
+ // Select the correct command to run, either enable or disable TrueDetect.
1188
+ const command = value ? 'EnableTrueDetect' : 'DisableTrueDetect'
1189
+
1190
+ // Log the update
1191
+ accessory.log(`${platformLang.curTrueDetect} [${value ? 'yes' : 'no'}]`)
1192
+
1193
+ // Send the command
1194
+ accessory.logDebug(`${platformLang.sendCmd} [${command}]`)
1195
+ accessory.control.run(command)
1196
+ } catch (err) {
1197
+ // Catch any errors during the process
1198
+ accessory.logWarn(`${platformLang.cleanFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`)
1199
+
1200
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
1201
+ setTimeout(() => {
1202
+ accessory
1203
+ .getService('Clean')
1204
+ .updateCharacteristic(this.cusChar.TrueDetect, false)
1205
+ }, 2000)
1206
+ throw new this.hapErr(-70402)
1207
+ }
1208
+ }
1209
+
1210
+ externalReadyUpdate(accessory) {
1211
+ try {
1212
+ // Called on the 'ready' event sent by the device so request update for states
1213
+ accessory.logDebug(`${platformLang.sendCmd} [GetBatteryState]`)
1214
+ accessory.control.run('GetBatteryState')
1215
+
1216
+ accessory.log(`${platformLang.sendCmd} [GetChargeState]`)
1217
+ accessory.control.run('GetChargeState')
1218
+
1219
+ accessory.logDebug(`${platformLang.sendCmd} [GetCleanState${accessory.context.commandSuffix}]`)
1220
+ accessory.control.run(`GetCleanState${accessory.context.commandSuffix}`)
1221
+
1222
+ accessory.logDebug(`${platformLang.sendCmd} [GetCleanSpeed]`)
1223
+ accessory.control.run('GetCleanSpeed')
1224
+
1225
+ accessory.logDebug(`${platformLang.sendCmd} [GetNetInfo]`)
1226
+ accessory.control.run('GetNetInfo')
1227
+
1228
+ accessory.logDebug(`${platformLang.sendCmd} [GetMaps]`)
1229
+ accessory.control.run('GetMaps')
1230
+ } catch (err) {
1231
+ // Catch any errors during the process
1232
+ accessory.logWarn(`${platformLang.inRdyFail} ${parseError(err)}`)
1233
+ }
1234
+ }
1235
+
1236
+ externalCleanUpdate(accessory, newVal) {
1237
+ try {
1238
+ // Log the received update
1239
+ accessory.logDebug(`${platformLang.receiveCmd} [CleanReport: ${newVal}]`)
1240
+
1241
+ // Check if the new cleaning state is different from the cached state
1242
+ if (accessory.context.cacheClean !== newVal) {
1243
+ // State is different so update service
1244
+ accessory
1245
+ .getService('Clean')
1246
+ .updateCharacteristic(
1247
+ this.hapChar.On,
1248
+ ['auto', 'clean', 'edge', 'spot', 'spotarea', 'customarea'].includes(
1249
+ newVal.toLowerCase().replace(/[^a-z]+/g, ''),
1250
+ ),
1251
+ )
1252
+
1253
+ // Log the change
1254
+ accessory.log(`${platformLang.curCleaning} [${newVal}]`)
1255
+ }
1256
+
1257
+ // Always update the cache with the new cleaning status
1258
+ accessory.context.cacheClean = newVal
1259
+ } catch (err) {
1260
+ // Catch any errors during the process
1261
+ accessory.logWarn(`${platformLang.inClnFail} ${parseError(err)}`)
1262
+ }
1263
+ }
1264
+
1265
+ externalSpeedUpdate(accessory, newVal) {
1266
+ try {
1267
+ // Log the received update
1268
+ accessory.logDebug(`${platformLang.receiveCmd} [CleanSpeed: ${newVal}]`)
1269
+
1270
+ // Check if the new cleaning state is different from the cached state
1271
+ if (accessory.context.cacheSpeed !== newVal) {
1272
+ // State is different so update service
1273
+ accessory
1274
+ .getService('Clean')
1275
+ .updateCharacteristic(this.cusChar.MaxSpeed, [3, 4].includes(newVal))
1276
+
1277
+ // Log the change
1278
+ accessory.log(`${platformLang.curSpeed} [${platformConsts.speed2Label[newVal]}]`)
1279
+ }
1280
+
1281
+ // Always update the cache with the new speed status
1282
+ accessory.context.cacheSpeed = newVal
1283
+ } catch (err) {
1284
+ // Catch any errors during the process
1285
+ accessory.logWarn(`${platformLang.inSpdFail} ${parseError(err)}`)
1286
+ }
1287
+ }
1288
+
1289
+ externalAirDryingUpdate(accessory, newVal) {
1290
+ try {
1291
+ // Log the received update
1292
+ accessory.logDebug(`${platformLang.receiveCmd} [AirDryingState: ${newVal}]`)
1293
+
1294
+ // Check if the new drying state is different from the cached state
1295
+ if (accessory.context.cacheAirDrying !== newVal) {
1296
+ // State is different so update service
1297
+ accessory
1298
+ .getService('Air Drying')
1299
+ .updateCharacteristic(this.hapChar.On, newVal === 'airdrying')
1300
+
1301
+ // Log the change
1302
+ accessory.log(`${platformLang.curAirDrying} [${newVal}]`)
1303
+ }
1304
+
1305
+ // Always update the cache with the new drying status
1306
+ accessory.context.cacheAirDrying = newVal
1307
+ } catch (err) {
1308
+ // Catch any errors during the process
1309
+ accessory.logWarn(`${platformLang.inAirFail} ${parseError(err)}`)
1310
+ }
1311
+ }
1312
+
1313
+ externalTrueDetectUpdate(accessory, newVal) {
1314
+ try {
1315
+ // Log the received update
1316
+ accessory.logDebug(`${platformLang.receiveCmd} [TrueDetect: ${newVal}]`)
1317
+
1318
+ // Check if the new charging state is different from the cached state
1319
+ if (accessory.context.trueDetect !== newVal) {
1320
+ // State is different so update service
1321
+ accessory
1322
+ .getService('Clean')
1323
+ .updateCharacteristic(this.cusChar.TrueDetect, newVal === 1)
1324
+
1325
+ // Log the change
1326
+ accessory.log(`${platformLang.curTrueDetect} [${newVal === 1 ? 'enabled' : 'disabled'}]`)
1327
+ }
1328
+
1329
+ // Always update the cache with the new charging status
1330
+ accessory.context.trueDetect = newVal
1331
+ } catch (err) {
1332
+ // Catch any errors during the process
1333
+ accessory.logWarn(`${platformLang.inTrDFail} ${parseError(err)}`)
1334
+ }
1335
+ }
1336
+
1337
+ externalChargeUpdate(accessory, newVal) {
1338
+ try {
1339
+ // Log the received update
1340
+ accessory.logDebug(`${platformLang.receiveCmd} [ChargeState: ${newVal}]`)
1341
+
1342
+ // Check if the new charging state is different from the cached state
1343
+ if (accessory.context.cacheCharge !== newVal) {
1344
+ // State is different so update service
1345
+ accessory
1346
+ .getService('Go Charge')
1347
+ .updateCharacteristic(this.hapChar.On, newVal === 'returning')
1348
+ const chargeState = newVal === 'charging' ? 1 : 0
1349
+ accessory
1350
+ .getService(this.hapServ.Battery)
1351
+ .updateCharacteristic(this.hapChar.ChargingState, chargeState)
1352
+
1353
+ // Log the change
1354
+ accessory.log(`${platformLang.curCharging} [${newVal}]`)
1355
+ }
1356
+
1357
+ // Always update the cache with the new charging status
1358
+ accessory.context.cacheCharge = newVal
1359
+ } catch (err) {
1360
+ // Catch any errors during the process
1361
+ accessory.logWarn(`${platformLang.inChgFail} ${parseError(err)}`)
1362
+ }
1363
+ }
1364
+
1365
+ externalIPUpdate(accessory, newVal) {
1366
+ try {
1367
+ // Log the received update
1368
+ accessory.logDebug(`${platformLang.receiveCmd} [NetInfoIP: ${newVal}]`)
1369
+
1370
+ // Check if the new IP is different from the cached IP
1371
+ if (accessory.context.ipAddress !== newVal) {
1372
+ // IP is different so update context info
1373
+ accessory.context.ipAddress = newVal
1374
+
1375
+ // Update the changes to the accessory to the platform
1376
+ this.api.updatePlatformAccessories([accessory])
1377
+ devicesInHB.set(accessory.UUID, accessory)
1378
+ }
1379
+ } catch (err) {
1380
+ // Catch any errors during the process
1381
+ }
1382
+ }
1383
+
1384
+ externalMacUpdate(accessory, newVal) {
1385
+ try {
1386
+ // Log the received update
1387
+ accessory.logDebug(`${platformLang.receiveCmd} [NetInfoMAC: ${newVal}]`)
1388
+
1389
+ // Check if the new MAC is different from the cached MAC
1390
+ if (accessory.context.macAddress !== newVal) {
1391
+ // MAC is different so update context info
1392
+ accessory.context.macAddress = newVal
1393
+
1394
+ // Update the changes to the accessory to the platform
1395
+ this.api.updatePlatformAccessories([accessory])
1396
+ devicesInHB.set(accessory.UUID, accessory)
1397
+ }
1398
+ } catch (err) {
1399
+ // Catch any errors during the process
1400
+ }
1401
+ }
1402
+
1403
+ async externalBatteryUpdate(accessory, newVal) {
1404
+ try {
1405
+ // Mark the device as online if it was offline before
1406
+ accessory.context.hadResponse = true
1407
+
1408
+ // Log the received update
1409
+ accessory.logDebug(`${platformLang.receiveCmd} [BatteryInfo: ${newVal}]`)
1410
+
1411
+ // Check the value given is between 0 and 100
1412
+ newVal = Math.min(Math.max(Math.round(newVal), 0), 100)
1413
+
1414
+ // Check if the new battery value is different from the cached state
1415
+ if (accessory.context.cacheBattery !== newVal) {
1416
+ // Value is different so update services
1417
+ const threshold = accessory.context.rawConfig.lowBattThreshold
1418
+ const lowBattStatus = newVal <= threshold ? 1 : 0
1419
+ accessory
1420
+ .getService(this.hapServ.Battery)
1421
+ .updateCharacteristic(this.hapChar.BatteryLevel, newVal)
1422
+ accessory
1423
+ .getService(this.hapServ.Battery)
1424
+ .updateCharacteristic(this.hapChar.StatusLowBattery, lowBattStatus)
1425
+
1426
+ // Also update the 'battery' humidity service if it exists
1427
+ if (accessory.context.rawConfig.showBattHumidity) {
1428
+ accessory
1429
+ .getService('Battery Level')
1430
+ .updateCharacteristic(this.hapChar.CurrentRelativeHumidity, newVal)
1431
+ }
1432
+
1433
+ // Log the change
1434
+ accessory.log(`${platformLang.curBatt} [${newVal}%]`)
1435
+
1436
+ // If the user wants a message and a buzz from the motion sensor then do it
1437
+ if (
1438
+ accessory.context.rawConfig.showMotionLowBatt
1439
+ && newVal <= accessory.context.rawConfig.lowBattThreshold
1440
+ && !accessory.cacheShownMotionLowBatt
1441
+ ) {
1442
+ await this.externalMessageUpdate(accessory, `${platformLang.lowBattMsg + newVal}%`)
1443
+ accessory.cacheShownMotionLowBatt = true
1444
+ }
1445
+
1446
+ // Revert the cache to false once the device has charged above the threshold
1447
+ if (newVal > accessory.context.rawConfig.lowBattThreshold) {
1448
+ accessory.cacheShownMotionLowBatt = false
1449
+ }
1450
+ }
1451
+
1452
+ // Always update the cache with the new battery value
1453
+ accessory.context.cacheBattery = newVal
1454
+ } catch (err) {
1455
+ // Catch any errors during the process
1456
+ accessory.logWarn(`${platformLang.inBattFail} ${parseError(err)}`)
1457
+ }
1458
+ }
1459
+
1460
+ async externalMessageUpdate(accessory, msg) {
1461
+ try {
1462
+ // Don't bother logging the same message as before
1463
+ if (accessory.context.lastMsg === msg) {
1464
+ return
1465
+ }
1466
+ accessory.context.lastMsg = msg
1467
+
1468
+ // Check if it's a no error message
1469
+ if (msg === 'NoError: Robot is operational') {
1470
+ return
1471
+ }
1472
+
1473
+ // Check to see if the motion sensor is already in use
1474
+ if (accessory.cacheInUse) {
1475
+ return
1476
+ }
1477
+ accessory.cacheInUse = true
1478
+
1479
+ // Log the message sent from the device
1480
+ accessory.log(`${platformLang.sentMsg} [${msg}]`)
1481
+
1482
+ // Update the motion sensor to motion detected if it exists
1483
+ if (accessory.getService('Attention')) {
1484
+ accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, true)
1485
+ }
1486
+
1487
+ // The motion sensor stays on for the time configured by the user, so we wait
1488
+ setTimeout(() => {
1489
+ // Reset the motion sensor after waiting for the time above if it exists
1490
+ if (accessory.getService('Attention')) {
1491
+ accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, false)
1492
+ }
1493
+
1494
+ // Update the inUse cache to false as we are complete here
1495
+ accessory.cacheInUse = false
1496
+ }, accessory.context.rawConfig.motionDuration * 1000)
1497
+ } catch (err) {
1498
+ // Catch any errors in the process
1499
+ accessory.logWarn(`${platformLang.inMsgFail} ${parseError(err)}`)
1500
+ }
1501
+ }
1502
+
1503
+ async externalErrorUpdate(accessory, err) {
1504
+ try {
1505
+ // Check if it's an offline notification but device was online
1506
+ if (err === 'Recipient unavailable' && accessory.context.isOnline) {
1507
+ accessory.log(platformLang.repOffline)
1508
+ accessory.context.isOnline = false
1509
+ this.api.updatePlatformAccessories([accessory])
1510
+ devicesInHB.set(accessory.UUID, accessory)
1511
+ }
1512
+
1513
+ // Check if it's a no error message
1514
+ if (err === 'NoError: Robot is operational') {
1515
+ return
1516
+ }
1517
+
1518
+ // Don't bother logging the same message as before
1519
+ if (accessory.context.lastMsg === err) {
1520
+ return
1521
+ }
1522
+ accessory.context.lastMsg = err
1523
+
1524
+ // Log the message sent from the device
1525
+ accessory.logWarn(`${platformLang.sentErr} [${err}]`)
1526
+
1527
+ // Check to see if the motion sensor is already in use
1528
+ if (accessory.cacheInUse) {
1529
+ return
1530
+ }
1531
+ accessory.cacheInUse = true
1532
+
1533
+ // Update the motion sensor to motion detected if it exists
1534
+ if (accessory.getService('Attention')) {
1535
+ accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, true)
1536
+ }
1537
+
1538
+ // The device has an error so turn both 'clean' and 'charge' switches off
1539
+ accessory.getService('Clean').updateCharacteristic(this.hapChar.On, false)
1540
+ accessory.getService('Go Charge').updateCharacteristic(this.hapChar.On, false)
1541
+
1542
+ // The motion sensor stays on for the time configured by the user, so we wait
1543
+ setTimeout(() => {
1544
+ // Reset the motion sensor after waiting for the time above if it exists
1545
+ if (accessory.getService('Attention')) {
1546
+ accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, false)
1547
+ }
1548
+
1549
+ // Update the inUse cache to false as we are complete here
1550
+ accessory.cacheInUse = false
1551
+ }, accessory.context.rawConfig.motionDuration * 1000)
1552
+ } catch (error) {
1553
+ // Catch any errors in the process
1554
+ accessory.logWarn(`${platformLang.inErrFail} ${parseError(error)}`)
1555
+ }
1556
+ }
1557
+ }