UNPKG

@homebridge-plugins/homebridge-ecovacs

Version:

Homebridge plugin to integrate Ecovacs Deebot devices into HomeKit.

1,304 lines (1,147 loc) 59.8 kB
import { Buffer } from 'node:buffer' import { createRequire } from 'node:module' import process from 'node:process' import { countries, EcoVacsAPI } from 'ecovacs-deebot' import platformConsts from './utils/constants.js' import platformChars from './utils/custom-chars.js' import { parseError, sleep } from './utils/functions.js' import platformLang from './utils/lang-en.js' const require = createRequire(import.meta.url) const plugin = require('../package.json') const devicesInHB = new Map() export default class { constructor(log, config, api) { if (!log || !api) { return } // Begin plugin initialisation try { this.api = api this.log = log this.isBeta = plugin.version.includes('beta') // Configuration objects for accessories this.deviceConf = {} this.ignoredDevices = [] // Make sure user is running Homebridge v1.6 or above if (!api?.versionGreaterOrEqual('1.6.0')) { throw new Error(platformLang.hbVersionFail) } // Check the user has configured the plugin if (!config) { throw new Error(platformLang.pluginNotConf) } // Log some environment info for debugging this.log( '%s v%s | System %s | Node %s | HB v%s | HAPNodeJS v%s...', platformLang.initialising, plugin.version, process.platform, process.version, api.serverVersion, api.hap.HAPLibraryVersion(), ) // Check the user has entered the required config fields if (!config.username || !config.password || !config.countryCode) { throw new Error(platformLang.missingCreds) } // Apply the user's configuration this.config = platformConsts.defaultConfig this.applyUserConfig(config) // Create further variables needed by the plugin this.hapErr = api.hap.HapStatusError this.hapChar = api.hap.Characteristic this.hapServ = api.hap.Service // Set up the Homebridge events this.api.on('didFinishLaunching', () => this.pluginSetup()) this.api.on('shutdown', () => this.pluginShutdown()) } catch (err) { // Catch any errors during initialisation log.warn('***** %s. *****', platformLang.disabling) log.warn('***** %s. *****', parseError(err, [ platformLang.hbVersionFail, platformLang.pluginNotConf, platformLang.missingCreds, platformLang.invalidCCode, platformLang.invalidPassword, platformLang.invalidUsername, ])) } } applyUserConfig(config) { // These shorthand functions save line space during config parsing const logDefault = (k, def) => { this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgDef, def) } const logDuplicate = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup) } const logIgnore = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn) } const logIgnoreItem = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem) } const logIncrease = (k, min) => { this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min) } const logQuotes = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts) } const logRemove = (k) => { this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv) } // Begin applying the user's config Object.entries(config).forEach((entry) => { const [key, val] = entry switch (key) { case 'countryCode': if (typeof val !== 'string' || val === '') { throw new Error(platformLang.invalidCCode) } this.config.countryCode = val.toUpperCase().replace(/[^A-Z]+/g, '') if (!Object.keys(countries).includes(this.config.countryCode)) { throw new Error(platformLang.invalidCCode) } break case 'devices': if (Array.isArray(val) && val.length > 0) { val.forEach((x) => { if (!x.deviceId) { logIgnoreItem(key) return } const id = x.deviceId.replace(/\s+/g, '') if (Object.keys(this.deviceConf).includes(id)) { logDuplicate(`${key}.${id}`) return } const entries = Object.entries(x) if (entries.length === 1) { logRemove(`${key}.${id}`) return } this.deviceConf[id] = platformConsts.defaultDevice entries.forEach((subEntry) => { const [k, v] = subEntry switch (k) { case 'areaNote1': case 'areaNote2': case 'areaNote3': case 'areaNote4': case 'areaNote5': case 'areaNote6': case 'areaNote7': case 'areaNote8': case 'areaNote9': case 'areaNote10': case 'areaNote11': case 'areaNote12': case 'areaNote13': case 'areaNote14': case 'areaNote15': // Just ignore the command notes as they are intended for user information during configuration only. break case 'areaType1': case 'areaType2': case 'areaType3': case 'areaType4': case 'areaType5': case 'areaType6': case 'areaType7': case 'areaType8': case 'areaType9': case 'areaType10': case 'areaType11': case 'areaType12': case 'areaType13': case 'areaType14': case 'areaType15': if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { // Just take over the command type as it comes from a string enumeration. this.deviceConf[id][k] = v } break case 'customAreaCoordinates1': case 'customAreaCoordinates2': case 'customAreaCoordinates3': case 'customAreaCoordinates4': case 'customAreaCoordinates5': case 'customAreaCoordinates6': case 'customAreaCoordinates7': case 'customAreaCoordinates8': case 'customAreaCoordinates9': case 'customAreaCoordinates10': case 'customAreaCoordinates11': case 'customAreaCoordinates12': case 'customAreaCoordinates13': case 'customAreaCoordinates14': case 'customAreaCoordinates15': { if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { // Strip off everything else than signs, figures, periods and commas. const stripped = v.replace(/[^-\d.,]+/g, '') if (stripped) { this.deviceConf[id][k] = stripped } else { logIgnore(`${key}.${id}.${k}`) } } break } case 'deviceId': case 'label': break case 'hideMotionSensor': case 'showBattHumidity': case 'showMotionLowBatt': case 'supportTrueDetect': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } this.deviceConf[id][k] = v === 'false' ? false : !!v break case 'ignoreDevice': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } if (!!v && v !== 'false') { this.ignoredDevices.push(id) } break case 'lowBattThreshold': case 'motionDuration': { if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } const intVal = Number.parseInt(v, 10) if (Number.isNaN(intVal)) { logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k]) this.deviceConf[id][k] = platformConsts.defaultValues[k] } else if (intVal < platformConsts.minValues[k]) { logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k]) this.deviceConf[id][k] = platformConsts.minValues[k] } else { this.deviceConf[id][k] = intVal } break } case 'pollInterval': { if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } const intVal = Number.parseInt(v, 10) if (Number.isNaN(intVal)) { logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k]) this.deviceConf[id][k] = platformConsts.defaultValues[k] } else if (intVal === 0) { this.deviceConf[id][k] = intVal } else if (intVal < platformConsts.minValues[k]) { logIncrease(key, platformConsts.minValues[k]) this.deviceConf[id][k] = platformConsts.minValues[k] } else { this.deviceConf[id][k] = intVal } break } case 'showAirDryingSwitch': { const inSet = platformConsts.allowed[k].includes(v) if (typeof v !== 'string' || !inSet) { logIgnore(`${key}.${id}.${k}`) } else { this.deviceConf[id][k] = inSet ? v : platformConsts.defaultValues[k] } break } case 'spotAreaIDs1': case 'spotAreaIDs2': case 'spotAreaIDs3': case 'spotAreaIDs4': case 'spotAreaIDs5': case 'spotAreaIDs6': case 'spotAreaIDs7': case 'spotAreaIDs8': case 'spotAreaIDs9': case 'spotAreaIDs10': case 'spotAreaIDs11': case 'spotAreaIDs12': case 'spotAreaIDs13': case 'spotAreaIDs14': case 'spotAreaIDs15': { if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { // Strip off everything else than figures and commas. const stripped = v.replace(/[^\d,]+/g, '') if (stripped) { this.deviceConf[id][k] = stripped } else { logIgnore(`${key}.${id}.${k}`) } } break } default: logRemove(`${key}.${id}.${k}`) } }) }) } else { logIgnore(key) } break case 'disableDeviceLogging': case 'useYeedi': if (typeof val === 'string') { logQuotes(key) } this.config[key] = val === 'false' ? false : !!val break case 'name': case 'platform': break case 'password': if (typeof val !== 'string' || val === '') { throw new Error(platformLang.invalidPassword) } this.config.password = val break case 'username': if (typeof val !== 'string' || val === '') { throw new Error(platformLang.invalidUsername) } this.config.username = val.replace(/\s+/g, '') break default: logRemove(key) break } }) } async pluginSetup() { // Plugin has finished initialising so now onto setup try { // Log that the plugin initialisation has been successful this.log('%s.', platformLang.initialised) // Sort out some logging functions if (this.isBeta) { this.log.debug = this.log this.log.debugWarn = this.log.warn // Log that using a beta will generate a lot of debug logs if (this.isBeta) { const divide = '*'.repeat(platformLang.beta.length + 1) // don't forget the full stop (+1!) this.log.warn(divide) this.log.warn(`${platformLang.beta}.`) this.log.warn(divide) } } else { this.log.debug = () => {} this.log.debugWarn = () => {} } // Require any libraries that the accessory instances use this.cusChar = new platformChars(this.api) // Connect to Ecovacs/Yeedi this.ecovacsAPI = new EcoVacsAPI( EcoVacsAPI.getDeviceId(this.api.hap.uuid.generate(this.config.username)), this.config.countryCode, countries[this.config.countryCode].continent, this.config.useYeedi ? 'yeedi.com' : 'ecovacs.com', ) // Display version of the ecovacs-deebot library in the log this.log('%s v%s.', platformLang.ecovacsLibVersion, this.ecovacsAPI.getVersion()) // Attempt to log in to Ecovacs/Yeedi try { await this.ecovacsAPI.connect(this.config.username, EcoVacsAPI.md5(this.config.password)) } catch (err) { // Check if password error and reattempt with base64 decoded version of password if (err.message?.includes('1010')) { this.config.password = Buffer.from(this.config.password, 'base64') .toString('utf8') .replace(/\r\n|\n|\r/g, '') .trim() await this.ecovacsAPI.connect( this.config.username, EcoVacsAPI.md5(this.config.password), ) } else { throw err } } // Get a device list from Ecovacs/Yeedi const deviceList = await this.ecovacsAPI.devices() // Check the request for device list was successful if (!Array.isArray(deviceList)) { throw new TypeError(platformLang.deviceListFail) } // Initialise each device into Homebridge this.log('[%s] %s.', deviceList.length, platformLang.deviceCount(this.config.useYeedi ? 'Yeedi' : 'Ecovacs')) for (let i = 0; i < deviceList.length; i += 1) { await this.initialiseDevice(deviceList[i]) } // Start the polling intervals for device state refresh, each device may have a different refresh time // We add a refresh interval per device later in initialiseDevice() this.refreshIntervals = {} // Setup successful this.log('%s. %s', platformLang.complete, platformLang.welcome) } catch (err) { // Catch any errors during setup this.log.warn('***** %s. *****', platformLang.disabling) this.log.warn('***** %s. *****', parseError(err, [platformLang.deviceListFail])) this.pluginShutdown() } } pluginShutdown() { // A function that is called when the plugin fails to load or Homebridge restarts try { // Stop the refresh intervals Object.keys(this.refreshIntervals).forEach((id) => { clearInterval(this.refreshIntervals[id]) }) // Disconnect from each Ecovacs/Yeedi device devicesInHB.forEach((accessory) => { if (accessory.control?.is_ready) { accessory.control.disconnect() } }) } catch (err) { // No need to show errors at this point } } initialiseDevice(device) { try { // Generate the Homebridge UUID from the device id const uuid = this.api.hap.uuid.generate(device.did) // If the accessory is in the ignored devices list then remove it if (this.ignoredDevices.includes(device.did)) { if (devicesInHB.has(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } return } // Load the device control information from Ecovacs/Yeedi const loadedDevice = this.ecovacsAPI.getVacBot( this.ecovacsAPI.uid, EcoVacsAPI.REALM, this.ecovacsAPI.resource, this.ecovacsAPI.user_access_token, device, countries[this.config.countryCode].continent, ) // Get the cached accessory or add to Homebridge if it doesn't exist const accessory = devicesInHB.get(uuid) || this.addAccessory(loadedDevice) accessory.context.rawConfig = this.deviceConf?.[device.did] || platformConsts.defaultDevice // Final check the accessory now exists in Homebridge if (!accessory) { throw new Error(platformLang.accNotFound) } // Sort out some logging functions per accessory if (this.isBeta) { accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) } else { if (this.config.disableDeviceLogging) { accessory.log = () => {} accessory.logWarn = () => {} } else { accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg) accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg) } accessory.logDebug = () => {} accessory.logDebugWarn = () => {} } // Initially set the device online value to false (to be updated later) accessory.context.isOnline = false accessory.context.lastMsg = '' // Remove the existing cache values from the context // accessory.context.cacheClean // accessory.context.cacheCharge // accessory.context.cacheAirDrying // accessory.context.cacheSpeed // accessory.context.cacheBattery const cacheKeys = [ 'cacheClean', 'cacheCharge', 'cacheAirDrying', 'cacheSpeed', 'cacheBattery', ] cacheKeys.forEach(key => delete accessory.context[key]) // Add the 'clean' switch service if it doesn't already exist const cleanService = accessory.getService('Clean') || accessory.addService(this.hapServ.Switch, 'Clean', 'clean') if (!cleanService.testCharacteristic(this.hapChar.ConfiguredName)) { cleanService.addCharacteristic(this.hapChar.ConfiguredName) cleanService.updateCharacteristic(this.hapChar.ConfiguredName, 'Clean') } if (!cleanService.testCharacteristic(this.hapChar.ServiceLabelIndex)) { cleanService.addCharacteristic(this.hapChar.ServiceLabelIndex) cleanService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 1) } // Add the 'charge' switch service if it doesn't already exist const chargeService = accessory.getService('Go Charge') || accessory.addService(this.hapServ.Switch, 'Go Charge', 'gocharge') if (!chargeService.testCharacteristic(this.hapChar.ConfiguredName)) { chargeService.addCharacteristic(this.hapChar.ConfiguredName) chargeService.updateCharacteristic(this.hapChar.ConfiguredName, 'Go Charge') } if (!chargeService.testCharacteristic(this.hapChar.ServiceLabelIndex)) { chargeService.addCharacteristic(this.hapChar.ServiceLabelIndex) chargeService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 2) } // Check if the speed characteristic has been added if (!cleanService.testCharacteristic(this.cusChar.MaxSpeed)) { cleanService.addCharacteristic(this.cusChar.MaxSpeed) } // Add the Eve characteristic for custom commands if any exist if (accessory.context.rawConfig.areaType1) { if (!cleanService.testCharacteristic(this.cusChar.PredefinedArea)) { cleanService.addCharacteristic(this.cusChar.PredefinedArea) } // Add the set characteristic cleanService .getCharacteristic(this.cusChar.PredefinedArea) .onSet(async value => this.internalPredefinedAreaUpdate(accessory, value)) } else if (cleanService.testCharacteristic(this.cusChar.PredefinedArea)) { cleanService.removeCharacteristic(cleanService.getCharacteristic(this.cusChar.PredefinedArea)) } // Add the set handler to the 'clean' switch on/off characteristic cleanService .getCharacteristic(this.hapChar.On) .updateValue(accessory.context.cacheClean === 'auto') .removeOnSet() .onSet(async value => this.internalCleanUpdate(accessory, value)) // Add the set handler to the 'max speed' switch on/off characteristic cleanService.getCharacteristic(this.cusChar.MaxSpeed) .onSet(async value => this.internalSpeedUpdate(accessory, value)) // Add the set handler to the 'charge' switch on/off characteristic chargeService .getCharacteristic(this.hapChar.On) .updateValue(accessory.context.cacheCharge === 'charging') .removeOnSet() .onSet(async value => this.internalChargeUpdate(accessory, value)) // Add the 'attention' motion service if it doesn't already exist if (!accessory.getService('Attention') && !accessory.context.rawConfig.hideMotionSensor) { accessory.addService(this.hapServ.MotionSensor, 'Attention', 'attention') } // Remove the 'attention' motion service if it exists and user doesn't want it if (accessory.getService('Attention') && accessory.context.rawConfig.hideMotionSensor) { accessory.removeService(accessory.getService('Attention')) } // Set the motion sensor off if exists when the plugin initially loads if (accessory.getService('Attention')) { accessory.getService('Attention').updateCharacteristic(this.hapChar.MotionDetected, false) } // Add the battery service if it doesn't already exist if (!accessory.getService(this.hapServ.Battery)) { accessory.addService(this.hapServ.Battery) } // Add the 'battery' humidity service if it doesn't already exist and user wants it if (!accessory.getService('Battery Level') && accessory.context.rawConfig.showBattHumidity) { accessory.addService(this.hapServ.HumiditySensor, 'Battery Level', 'batterylevel') } // Remove the 'battery' humidity service if it exists and user doesn't want it if (accessory.getService('Battery Level') && !accessory.context.rawConfig.showBattHumidity) { accessory.removeService(accessory.getService('Battery Level')) } // 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 if ( accessory.context.rawConfig.showAirDryingSwitch === 'yes' || (accessory.context.rawConfig.showAirDryingSwitch === 'presetting' && loadedDevice.hasAirDrying()) ) { const dryingService = accessory.getService('Air Drying') || accessory.addService(this.hapServ.Switch, 'Air Drying', 'airdrying') if (!dryingService.testCharacteristic(this.hapChar.ConfiguredName)) { dryingService.addCharacteristic(this.hapChar.ConfiguredName) dryingService.updateCharacteristic(this.hapChar.ConfiguredName, 'Air Drying') } if (!dryingService.testCharacteristic(this.hapChar.ServiceLabelIndex)) { dryingService.addCharacteristic(this.hapChar.ServiceLabelIndex) dryingService.updateCharacteristic(this.hapChar.ServiceLabelIndex, 3) } dryingService .getCharacteristic(this.hapChar.On) .updateValue(accessory.context.cacheAirDrying === 'airdrying') .removeOnSet() .onSet(async value => this.internalAirDryingUpdate(accessory, value)) } else if (accessory.getService('Air Drying')) { accessory.removeService(accessory.getService('Air Drying')) accessory.logDebug('air drying service removed') } else { accessory.logDebug('no air drying available or not configured') } // TrueDetect service if (accessory.context.rawConfig.supportTrueDetect) { // Custom Eve characteristic like MaxSpeed if (!cleanService.testCharacteristic(this.cusChar.TrueDetect)) { cleanService.addCharacteristic(this.cusChar.TrueDetect) } // Add the set handler to the 'true detect' switch on/off characteristic cleanService.getCharacteristic(this.cusChar.TrueDetect) .onSet(async value => this.internalTrueDetectUpdate(accessory, value)) } else if (accessory.getService('TrueDetect')) { // Remove TrueDetect service if exists accessory.removeService(accessory.getService('TrueDetect')) } // Save the device control information to the accessory accessory.control = loadedDevice // Some models can use a V2 for supported commands accessory.context.commandSuffix = accessory.control.is950type_V2() ? '_V2' : '' // Set up a listener for the device 'ready' event accessory.control.on('ready', (event) => { if (event) { this.externalReadyUpdate(accessory) } }) // Set up a listener for the device 'CleanReport' event accessory.control.on('CleanReport', (newVal) => { this.externalCleanUpdate(accessory, newVal) }) // Set up a listener for the device 'CurrentCustomAreaValues' event accessory.control.on('CurrentCustomAreaValues', (newVal) => { accessory.logDebug(`CurrentCustomAreaValues: ${JSON.stringify(newVal)}`) }) // Set up a listener for the device 'CleanSpeed' event accessory.control.on('CleanSpeed', (newVal) => { this.externalSpeedUpdate(accessory, newVal) }) // Set up a listener for the device 'BatteryInfo' event accessory.control.on('BatteryInfo', async (newVal) => { await this.externalBatteryUpdate(accessory, newVal) }) // Set up a listener for the device 'AirDryingState' event // Only if the service exists if (accessory.getService('Air Drying')) { accessory.control.on('AirDryingState', (newVal) => { this.externalAirDryingUpdate(accessory, newVal) }) } // Set up a listener for the device 'ChargeState' event accessory.control.on('ChargeState', (newVal) => { this.externalChargeUpdate(accessory, newVal) }) // Set up a listener for the device 'NetInfoIP' event accessory.control.on('NetInfoIP', (newVal) => { this.externalIPUpdate(accessory, newVal) }) // Set up a listener for the device 'NetInfoMAC' event accessory.control.on('NetInfoMAC', (newVal) => { this.externalMacUpdate(accessory, newVal) }) if (accessory.context.rawConfig.supportTrueDetect) { // Set up a listener for the device 'TrueDetect' event accessory.control.on('TrueDetect', (newVal) => { this.externalTrueDetectUpdate(accessory, newVal) }) } // Set up a listener for the device 'message' event accessory.control.on('message', async (msg) => { await this.externalMessageUpdate(accessory, msg) }) // Set up a listener for the device 'Error' event accessory.control.on('Error', async (err) => { if (err) { await this.externalErrorUpdate(accessory, err) } }) // Set up listeners for map data if accessory debug logging is on accessory.control.on('Maps', (maps) => { if (maps) { accessory.logDebug(`Maps: ${JSON.stringify(maps)}`) Object.keys(maps.maps).forEach((key) => { accessory.control.run('GetSpotAreas', maps.maps[key].mapID) accessory.control.run('GetVirtualBoundaries', maps.maps[key].mapID) }) } }) accessory.control.on('MapSpotAreas', (spotAreas) => { if (spotAreas) { accessory.logDebug(`MapSpotAreas: ${JSON.stringify(spotAreas)}`) Object.keys(spotAreas.mapSpotAreas).forEach((key) => { accessory.control.run( 'GetSpotAreaInfo', spotAreas.mapID, spotAreas.mapSpotAreas[key].mapSpotAreaID, ) }) } }) accessory.control.on('MapSpotAreaInfo', (area) => { if (area) { accessory.logDebug(`MapSpotAreaInfo: ${JSON.stringify(area)}`) } }) accessory.control.on('MapVirtualBoundaries', (vbs) => { if (vbs) { accessory.logDebug(`MapVirtualBoundaries: ${JSON.stringify(vbs)}`) const vbsCombined = [...vbs.mapVirtualWalls, ...vbs.mapNoMopZones] const virtualBoundaryArray = [] Object.keys(vbsCombined).forEach((key) => { virtualBoundaryArray[vbsCombined[key].mapVirtualBoundaryID] = vbsCombined[key] }) Object.keys(virtualBoundaryArray).forEach((key) => { accessory.control.run( 'GetVirtualBoundaryInfo', vbs.mapID, virtualBoundaryArray[key].mapVirtualBoundaryID, virtualBoundaryArray[key].mapVirtualBoundaryType, ) }) } }) accessory.control.on('MapVirtualBoundaryInfo', (vb) => { if (vb) { accessory.logDebug(`MapVirtualBoundaryInfo: ${JSON.stringify(vb)}`) } }) // Connect to the device accessory.control.connect() // Refresh the current state of all the accessories this.refreshAccessory(accessory) const { pollInterval } = accessory.context.rawConfig[device] || platformConsts.defaultValues if (pollInterval > 0) { this.refreshIntervals[device.did] = setInterval(() => { devicesInHB.get(this.api.hap.uuid.generate(device.did)).control?.refresh() }, pollInterval * 1000) } // Update any changes to the accessory to the platform this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) // Log configuration and device initialisation this.log( '[%s] %s: %s.', accessory.displayName, platformLang.devInitOpts, JSON.stringify(accessory.context.rawConfig), ) this.log( '[%s] %s [%s] %s %s.', accessory.displayName, platformLang.devInit, device.did, platformLang.addInfo, JSON.stringify(device), ) // If after five seconds the device hasn't responded then mark as offline setTimeout(() => { if (!accessory.context.isOnline) { accessory.logWarn(platformLang.repOffline) } }, 5000) } catch (err) { const dName = device.nick || device.did this.log.warn('[%s] %s %s.', dName, platformLang.devNotInit, parseError(err, [platformLang.accNotFound])) this.log.warn(err) } } refreshAccessory(accessory) { try { // Check the device has initialised already if (!accessory.control) { return } // Set up a flag to check later if we have had a response accessory.context.hadResponse = false // Run the commands to get the state of the device accessory.logDebug(`${platformLang.sendCmd} [GetBatteryState]`) accessory.control.run('GetBatteryState') accessory.logDebug(`${platformLang.sendCmd} [GetChargeState]`) accessory.control.run('GetChargeState') if (accessory.getService('Air Drying')) { accessory.logDebug(`${platformLang.sendCmd} [GetAirDrying]`) accessory.control.run('GetAirDrying') } accessory.logDebug(`${platformLang.sendCmd} [GetCleanState${accessory.context.commandSuffix}]`) accessory.control.run(`GetCleanState${accessory.context.commandSuffix}`) accessory.logDebug(`${platformLang.sendCmd} [GetCleanSpeed]`) accessory.control.run('GetCleanSpeed') accessory.logDebug(`${platformLang.sendCmd} [GetNetInfo]`) accessory.control.run('GetNetInfo') // TrueDetect if the accessory supports it if (accessory.context.rawConfig.supportTrueDetect) { accessory.logDebug(`${platformLang.sendCmd} [GetTrueDetect]`) accessory.control.run('GetTrueDetect') } setTimeout(() => { if (!accessory.context.isOnline && accessory.context.hadResponse) { accessory.logDebug(platformLang.repOnline) accessory.context.isOnline = true this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) } if (accessory.context.isOnline && !accessory.context.hadResponse) { accessory.logDebug(platformLang.repOffline) accessory.context.isOnline = false this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) } }, 5000) } catch (err) { // Catch any errors in the refresh process accessory.logWarn(`${platformLang.devNotRef} ${parseError(err)}`) } } addAccessory(device) { // Add an accessory to Homebridge let displayName = 'Unknown' try { displayName = device.vacuum.nick || device.vacuum.did const accessory = new this.api.platformAccessory( displayName, this.api.hap.uuid.generate(device.vacuum.did), ) accessory .getService(this.hapServ.AccessoryInformation) .setCharacteristic(this.hapChar.Name, displayName) .setCharacteristic(this.hapChar.ConfiguredName, displayName) .setCharacteristic(this.hapChar.SerialNumber, device.vacuum.did) .setCharacteristic(this.hapChar.Manufacturer, device.vacuum.company) .setCharacteristic(this.hapChar.Model, device.deviceModel) .setCharacteristic(this.hapChar.Identify, true) // Add context information for Homebridge plugin-ui accessory.context.ecoDeviceId = device.vacuum.did accessory.context.ecoCompany = device.vacuum.company accessory.context.ecoModel = device.deviceModel accessory.context.ecoClass = device.vacuum.class accessory.context.ecoResource = device.vacuum.resource accessory.context.ecoImage = device.deviceImageURL this.api.registerPlatformAccessories(plugin.name, plugin.alias, [accessory]) devicesInHB.set(accessory.UUID, accessory) this.log('[%s] %s.', displayName, platformLang.devAdd) return accessory } catch (err) { // Catch any errors during add this.log.warn('[%s] %s %s.', displayName, platformLang.devNotAdd, parseError(err)) return false } } configureAccessory(accessory) { // Add the configured accessory to our global map devicesInHB.set(accessory.UUID, accessory) accessory .getService('Clean') .getCharacteristic(this.api.hap.Characteristic.On) .onSet(() => { this.log.warn('[%s] %s.', accessory.displayName, platformLang.accNotReady) throw new this.api.hap.HapStatusError(-70402) }) .updateValue(new this.api.hap.HapStatusError(-70402)) accessory .getService('Go Charge') .getCharacteristic(this.api.hap.Characteristic.On) .onSet(() => { this.log.warn('[%s] %s.', accessory.displayName, platformLang.accNotReady) throw new this.api.hap.HapStatusError(-70402) }) .updateValue(new this.api.hap.HapStatusError(-70402)) } removeAccessory(accessory) { // Remove an accessory from Homebridge try { this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory]) devicesInHB.delete(accessory.UUID) this.log('[%s] %s.', accessory.displayName, platformLang.devRemove) } catch (err) { // Catch any errors during remove this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotRemove, parseError(err)) } } async internalCleanUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // A one-second delay seems to make turning off the 'charge' switch more responsive await sleep(1) // Turn the 'charge' switch off since we have commanded the 'clean' switch accessory.getService('Go Charge').updateCharacteristic(this.hapChar.On, false) // Select the correct command to run, either start or stop cleaning const order = value ? `Clean${accessory.context.commandSuffix}` : 'Stop' // Log the update accessory.log(`${platformLang.curCleaning} [${value ? platformLang.cleaning : platformLang.stop}}]`) // Send the command accessory.logDebug(`${platformLang.sendCmd} [${order}]`) accessory.control.run(order) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.cleanFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory .getService('Clean') .updateCharacteristic(this.hapChar.On, accessory.context.cacheClean === 'auto') }, 2000) throw new this.hapErr(-70402) } } async internalSpeedUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // Set speed to max (3) if value is true otherwise set to standard (2) const command = value ? 3 : 2 // Log the update accessory.log(`${platformLang.curSpeed} [${platformConsts.speed2Label[command]}]`) // Send the command accessory.logDebug(`${platformLang.sendCmd} [SetCleanSpeed: ${command}]`) accessory.control.run('SetCleanSpeed', command) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.speedFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory .getService('Clean') .updateCharacteristic(this.cusChar.MaxSpeed, [3, 4].includes(accessory.context.cacheSpeed)) }, 2000) throw new this.hapErr(-70402) } } async internalPredefinedAreaUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // Eve app for some reason still sends values with decimal places value = Math.round(value) // A value of 0 doesn't do anything if (value === 0) { accessory.logDebugWarn(platformLang.returningAsValueNull) return } // Avoid quick switching with this function const updateKey = Math.random() .toString(36) .slice(2, 10) accessory.context.lastCommandKey = updateKey await sleep(1) if (updateKey !== accessory.context.lastCommandKey) { accessory.logWarn(platformLang.skippingValue) return } // Obtain the area type from the device config const areaType = accessory.context.rawConfig[`areaType${value}`] // Don't continue if no command type for this number has been configured if (!areaType) { throw new Error(`${platformLang.noTypeForArea}: ${value}`) } accessory.log(`${platformLang.typeForArea} ${value}: ${areaType}`) // Obtain the command from the device config const command = accessory.context.rawConfig[areaType === 'spotArea' ? `spotAreaIDs${value}` : `customAreaCoordinates${value}`] // Don't continue if no command for this number has been configured if (!command) { throw new Error(`${platformLang.noCommandForArea}: ${value}`) } accessory.log(`${platformLang.commandForArea} ${value}: ${command}`) // Send the command switch (areaType) { case 'spotArea': accessory.logDebug(`${platformLang.sendCmd} [SpotArea${accessory.context.commandSuffix}: ${command}]`) if (accessory.context.commandSuffix === '_V2') { accessory.control.run('SpotArea_V2', command) } else { accessory.control.run('SpotArea', 'start', command) } break case 'customArea': accessory.logDebug(`${platformLang.sendCmd} [CustomArea${accessory.context.commandSuffix}: ${command}]`) if (accessory.context.commandSuffix === '_V2') { accessory.control.run('CustomArea_V2', command) } else { accessory.control.run('CustomArea', 'start', command) } break default: throw new Error(`${areaType}: ${platformLang.unknownCommandTypeForArea}`) } accessory.log(platformLang.commandSent) // Set the value back to 0 after two seconds and turn the main ON switch on setTimeout(() => { accessory.getService('Clean').updateCharacteristic(this.cusChar.PredefinedArea, 0) accessory.getService('Clean').updateCharacteristic(this.hapChar.On, true) accessory.log(platformLang.characteristicsReset) }, 2000) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.speedFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory.getService('Clean').updateCharacteristic(this.cusChar.PredefinedArea, 0) }, 2000) throw new this.hapErr(-70402) } } async internalChargeUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // A one-second delay seems to make everything more responsive await sleep(1) // Don't continue if the device is already charging const battService = accessory.getService(this.hapServ.Battery) if (battService.getCharacteristic(this.hapChar.ChargingState).value !== 0) { return } // Select the correct command to run, either start or stop going to charge const order = value ? 'Charge' : 'Stop' // Log the update accessory.log(`${platformLang.curCharging} [${value ? platformLang.returning : platformLang.stop}]`) // Send the command accessory.logDebug(`${platformLang.sendCmd} [${order}]`) accessory.control.run(order) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.chargeFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory .getService('Go Charge') .updateCharacteristic(this.hapChar.On, accessory.context.cacheCharge === 'returning') }, 2000) throw new this.hapErr(-70402) } } async internalAirDryingUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // A one-second delay seems to make everything more responsive await sleep(1) // Select the correct command to run, either start or stop air drying. const order = value ? 'AirDryingStart' : 'AirDryingStop' // Send the command accessory.logDebug(`${platformLang.sendCmd} [${order}]`) accessory.control.run(order) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.airDryingFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory .getService('Air Drying') .updateCharacteristic(this.hapChar.On, accessory.context.cacheAirDrying === 'airdrying') }, 2000) throw new this.hapErr(-70402) } } async internalTrueDetectUpdate(accessory, value) { try { // Don't continue if we can't send commands to the device if (!accessory.control) { throw new Error(platformLang.errNotInit) } if (!accessory.control.is_ready) { throw new Error(platformLang.errNotReady) } // Select the correct command to run, either enable or disable TrueDetect. const command = value ? 'EnableTrueDetect' : 'DisableTrueDetect' // Log the update accessory.log(`${platformLang.curTrueDetect} [${value ? 'yes' : 'no'}]`) // Send the command accessory.logDebug(`${platformLang.sendCmd} [${command}]`) accessory.control.run(command) } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.cleanFail} ${parseError(err, [platformLang.errNotInit, platformLang.errNotReady])}`) // Throw a 'no response' error and set a timeout to revert this after 2 seconds setTimeout(() => { accessory .getService('Clean') .updateCharacteristic(this.cusChar.TrueDetect, false) }, 2000) throw new this.hapErr(-70402) } } externalReadyUpdate(accessory) { try { // Called on the 'ready' event sent by the device so request update for states accessory.logDebug(`${platformLang.sendCmd} [GetBatteryState]`) accessory.control.run('GetBatteryState') accessory.log(`${platformLang.sendCmd} [GetChargeState]`) accessory.control.run('GetChargeState') accessory.logDebug(`${platformLang.sendCmd} [GetCleanState${accessory.context.commandSuffix}]`) accessory.control.run(`GetCleanState${accessory.context.commandSuffix}`) accessory.logDebug(`${platformLang.sendCmd} [GetCleanSpeed]`) accessory.control.run('GetCleanSpeed') accessory.logDebug(`${platformLang.sendCmd} [GetNetInfo]`) accessory.control.run('GetNetInfo') accessory.logDebug(`${platformLang.sendCmd} [GetMaps]`) accessory.control.run('GetMaps') } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.inRdyFail} ${parseError(err)}`) } } externalCleanUpdate(accessory, newVal) { try { // Log the received update accessory.logDebug(`${platformLang.receiveCmd} [CleanReport: ${newVal}]`) // Check if the new cleaning state is different from the cached state if (accessory.context.cacheClean !== newVal) { // State is different so update service accessory .getService('Clean') .updateCharacteristic( this.hapChar.On, ['auto', 'clean', 'edge', 'spot', 'spotarea', 'customarea'].includes( newVal.toLowerCase().replace(/[^a-z]+/g, ''), ), ) // Log the change accessory.log(`${platformLang.curCleaning} [${newVal}]`) } // Always update the cache with the new cleaning status accessory.context.cacheClean = newVal } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.inClnFail} ${parseError(err)}`) } } externalSpeedUpdate(accessory, newVal) { try { // Log the received update accessory.logDebug(`${platformLang.receiveCmd} [CleanSpeed: ${newVal}]`) // Check if the new cleaning state is different from the cached state if (accessory.context.cacheSpeed !== newVal) { // State is different so update service accessory .getService('Clean') .updateCharacteristic(this.cusChar.MaxSpeed, [3, 4].includes(newVal)) // Log the change accessory.log(`${platformLang.curSpeed} [${platformConsts.speed2Label[newVal]}]`) } // Always update the cache with the new speed status accessory.context.cacheSpeed = newVal } catch (err) { // Catch any errors during the process accessory.logWarn(`${platformLang.inSpdFail} ${parseError(err)}`) } } externalAirDryingUpdate(accessor