UNPKG

homebridge-roborock

Version:

Xiaomi Vacuum Cleaner - 1st (Mi Robot), 2nd (Roborock S50 + S55), 3rd Generation (Roborock S6) and S5 Max - plugin for Homebridge.

906 lines 42.9 kB
'use strict'; const semver = require('semver'); const miio = require('miio'); const util = require('util'); const callbackify = require('./lib/callbackify'); const safeCall = require('./lib/safeCall'); const sleep = require('system-sleep'); let homebrideAPI, Service, Characteristic; //Changed by Mai const PLUGIN_NAME = 'homebridge-roborock'; const ACCESSORY_NAME = 'XiaomiRoborockVacuum'; const MODELS = require('./models'); const GET_STATE_INTERVAL_MS = 30000; // 30s module.exports = function(homebridge) { // Accessory = homebridge.platformAccessory; Service = homebridge.hap.Service; Characteristic = homebridge.hap.Characteristic; homebrideAPI = homebridge; // UUIDGen = homebridge.hap.uuid; homebridge.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, XiaomiRoborockVacuum); } class XiaomiRoborockVacuum { // From https://github.com/aholstenson/miio/blob/master/lib/devices/vacuum.js#L128 static get cleaningStatuses() { return ['cleaning', 'spot-cleaning', 'zone-cleaning', 'room-cleaning']; } static get errors() { return { id1: { description: 'Try turning the orange laserhead to make sure it isnt blocked.' }, id2: { description: 'Clean and tap the bumpers lightly.' }, id3: { description: 'Try moving the vacuum to a different place.' }, id4: { description: 'Wipe the cliff sensor clean and move the vacuum to a different place.' }, id5: { description: 'Remove and clean the main brush.' }, id6: { description: 'Remove and clean the sidebrushes.' }, id7: { description: 'Make sure the wheels arent blocked. Move the vacuum to a different place and try again.' }, id8: { description: 'Make sure there are no obstacles around the vacuum.' }, id9: { description: 'Install the dustbin and the filter.' }, id10: { description: 'Make sure the filter has been tried or clean the filter.' }, id11: { description: 'Strong magnetic field detected. Move the device away from the virtual wall and try again' }, id12: { description: 'Battery is low, charge your vacuum.' }, id13: { description: 'Couldnt charge properly. Make sure the charging surfaces are clean.' }, id14: { description: 'Battery malfunctioned.' }, id15: { description: 'Wipe the wall sensor clean.' }, id16: { description: 'Use the vacuum on a flat horizontal surface.' }, id17: { description: 'Sidebrushes malfunctioned. Reboot the system.' }, id18: { description: 'Fan malfunctioned. Reboot the system.' }, id19: { description: 'The docking station is not connected to power.' }, id20: { description: 'unkown' }, id21: { description: 'Please make sure that the top cover of the laser distance sensor is not pinned.' }, id22: { description: 'Please wipe the dock sensor.' }, id23: { description: 'Make sure the signal emission area of dock is clean.' } }; } constructor(log, config) { this.log = log; this.config = config; this.config.name = config.name || 'Roborock vacuum cleaner'; this.config.cleanword = config.cleanword || 'cleaning'; this.config.delay = config.delay || false; this.services = {}; // Used to store the latest state to reduce logging this.cachedState = new Map(); //Changed by Mai this.roomsToClean = []; this.device = null; this.connectingPromise = null; this.connectRetry = setTimeout(() => void 0, 100); // Noop timeout only to initialise the property this.getStateInterval = setInterval(() => void 0, GET_STATE_INTERVAL_MS); // Noop timeout only to initialise the property if (!this.config.ip) { throw new Error('You must provide an ip address of the vacuum cleaner.'); } if (!this.config.token) { throw new Error('You must provide a token of the vacuum cleaner.'); } // HOMEKIT SERVICES this.initialiseServices(); // Initialize device this.connect().catch(() => { // Do nothing in the catch because this function already logs the error internally and retries after 2 minutes. }); } initialiseServices() { this.services.info = new Service.AccessoryInformation(); this.services.info.setCharacteristic(Characteristic.Manufacturer, 'Xiaomi').setCharacteristic(Characteristic.Model, 'Roborock'); this.services.info.getCharacteristic(Characteristic.FirmwareRevision).on('get', (cb) => callbackify(() => this.getFirmware(), cb)); this.services.info.getCharacteristic(Characteristic.Model).on('get', (cb) => callbackify(() => this.device.miioModel, cb)); this.services.info.getCharacteristic(Characteristic.SerialNumber).on('get', (cb) => callbackify(() => this.getSerialNumber(), cb)); this.services.fan = new Service.Fan(this.config.name, 'Speed'); this.services.fan.getCharacteristic(Characteristic.On).on('get', (cb) => callbackify(() => this.getCleaning(), cb)).on('set', (newState, cb) => callbackify(() => this.setCleaning(newState), cb)).on('change', (oldState, newState) => { this.changedPause(newState); }); //this.services.fan //.getCharacteristic(Characteristic.RotationSpeed).on('get', (cb) => callbackify(() => this.getSpeed(), cb)) //.on('set', (newState, cb) => callbackify(() => this.setSpeed(newState), cb)); if (this.config.waterBox) { this.services.waterBox = new Service.Fan(`${this.config.name} Water Box`, 'Water Box'); // TODO: Do we need to manage the Characteristic.On? this.services.waterBox.getCharacteristic(Characteristic.RotationSpeed).on('get', (cb) => callbackify(() => this.getWaterSpeed(), cb)).on('set', (newState, cb) => callbackify(() => this.setWaterSpeed(newState), cb)); } this.services.battery = new Service.BatteryService(`${this.config.name} Battery`); this.services.battery.getCharacteristic(Characteristic.BatteryLevel).on('get', (cb) => callbackify(() => this.getBattery(), cb)); this.services.battery.getCharacteristic(Characteristic.ChargingState).on('get', (cb) => callbackify(() => this.getCharging(), cb)); this.services.battery.getCharacteristic(Characteristic.StatusLowBattery).on('get', (cb) => callbackify(() => this.getBatteryLow(), cb)); if (this.config.pause) { this.services.pause = new Service.Switch(`${this.config.name} Pause`, 'Pause Switch'); this.services.pause.getCharacteristic(Characteristic.On).on('get', (cb) => callbackify(() => this.getPauseState(), cb)).on('set', (newState, cb) => callbackify(() => this.setPauseState(newState), cb)); // TODO: Add 'change' status? } if (this.config.dock) { this.services.dock = new Service.OccupancySensor(`${this.config.name} Dock`); this.services.dock.getCharacteristic(Characteristic.OccupancyDetected).on('get', (cb) => callbackify(() => this.getDocked(), cb)); } if (this.config.rooms && !this.config.autoroom) { for (var i in this.config.rooms) { this.createRoom(this.config.rooms[i].id, this.config.rooms[i].name); } } if (this.config.zones) { for (var i in this.config.zones) { this.createZone(this.config.zones[i].name, this.config.zones[i].zone); } } //Changed by Mai if (this.config.gotoTarget) { if (this.config.gotoTarget.target && this.config.gotoTarget.target.length == 2) { var name = this.config.gotoTarget.name || 'Go to'; this.services.gotoTarget = new Service.Switch(name, 'GotoTargetSwitch'); this.services.gotoTarget.getCharacteristic(Characteristic.On).on('get', (cb) => callbackify(() => this.getGotoTarget(), cb)). on('set', (newState, cb) => callbackify(() => this.setGotoTarget(newState, this.config.gotoTarget.target), cb)); } } // ADDITIONAL HOMEKIT SERVICES this.initialiseCareServices(); } initialiseCareServices() { Characteristic.CareSensors = function() { Characteristic.call(this, 'Care indicator sensors', '00000101-0000-0000-0000-000000000000'); this.setProps({ format: Characteristic.Formats.FLOAT, unit: '%', perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); }; util.inherits(Characteristic.CareSensors, Characteristic); Characteristic.CareSensors.UUID = '00000101-0000-0000-0000-000000000000'; Characteristic.CareFilter = function() { Characteristic.call(this, 'Care indicator filter', '00000102-0000-0000-0000-000000000000'); this.setProps({ format: Characteristic.Formats.FLOAT, unit: '%', perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); }; util.inherits(Characteristic.CareFilter, Characteristic); Characteristic.CareFilter.UUID = '00000102-0000-0000-0000-000000000000'; Characteristic.CareSideBrush = function() { Characteristic.call(this, 'Care indicator side brush', '00000103-0000-0000-0000-000000000000'); this.setProps({ format: Characteristic.Formats.FLOAT, unit: '%', perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); }; util.inherits(Characteristic.CareSideBrush, Characteristic); Characteristic.CareSideBrush.UUID = '00000103-0000-0000-0000-000000000000'; Characteristic.CareMainBrush = function() { Characteristic.call(this, 'Care indicator main brush', '00000104-0000-0000-0000-000000000000'); this.setProps({ format: Characteristic.Formats.FLOAT, unit: '%', perms: [Characteristic.Perms.READ, Characteristic.Perms.NOTIFY] }); this.value = this.getDefaultValue(); }; util.inherits(Characteristic.CareMainBrush, Characteristic); Characteristic.CareMainBrush.UUID = '00000104-0000-0000-0000-000000000000'; Service.Care = function(displayName, subtype) { Service.call(this, displayName, '00000111-0000-0000-0000-000000000000', subtype); this.addCharacteristic(Characteristic.CareSensors); this.addCharacteristic(Characteristic.CareFilter); this.addCharacteristic(Characteristic.CareSideBrush); this.addCharacteristic(Characteristic.CareMainBrush); }; util.inherits(Service.Care, Service); Service.Care.UUID = '00000111-0000-0000-0000-000000000000'; this.services.Care = new Service.Care(`${this.config.name} Care`) this.services.Care.getCharacteristic(Characteristic.CareSensors).on('get', (cb) => callbackify(() => this.getCareSensors(), cb)); this.services.Care.getCharacteristic(Characteristic.CareFilter).on('get', (cb) => callbackify(() => this.getCareFilter(), cb)); this.services.Care.getCharacteristic(Characteristic.CareSideBrush).on('get', (cb) => callbackify(() => this.getCareSideBrush(), cb)); this.services.Care.getCharacteristic(Characteristic.CareMainBrush).on('get', (cb) => callbackify(() => this.getCareMainBrush(), cb)); } /** * Returns if the newValue is different to the previously cached one * * @param {string} property * @param {any} newValue * @returns {boolean} Whether the newValue is not the same as the previously cached one. */ isNewValue(property, newValue) { const cachedValue = this.cachedState.get(property); this.cachedState.set(property, newValue); return cachedValue !== newValue; } changedError(robotError) { this.log.debug(`DEB changedError | ${this.model} | ErrorID: ${robotError.id}, ErrorDescription: ${robotError.description}`); let robotErrorTxt = XiaomiRoborockVacuum.errors[`id${robotError.id}`] ? XiaomiRoborockVacuum.errors[`id${robotError.id}`].description : `Unknown ERR | errorid can't be mapped. (${robotError.id})`; if (!`${robotError.description}`.toLowerCase().startsWith('unknown')) { robotErrorTxt = robotError.description; } this.log.warn(`WAR changedError | ${this.model} | Robot has an ERROR - ${robotError.id}, ${robotErrorTxt}`); } changedCleaning(isCleaning) { if (this.isNewValue('cleaning', isCleaning)) { this.log.debug(`MON changedCleaning | ${this.model} | CleaningState is now ${isCleaning}`); this.log.info(`INF changedCleaning | ${this.model} | Cleaning is ${isCleaning ? 'ON' : 'OFF'}.`); //Changed by Mai if (!isCleaning) { this.roomsToClean.length = 0; } } // We still update the value in Homebridge. If we are calling the changed method is because we want to change it. this.services.fan.getCharacteristic(Characteristic.On).updateValue(isCleaning); } changedPause(isCleaning) { if (this.config.pause) { if (this.isNewValue('pause', isCleaning)) { this.log.debug(`MON changedPause | ${this.model} | CleaningState is now ${isCleaning}`); this.log.info(`INF changedPause | ${this.model} | ${isCleaning ? 'Paused possible' : 'Paused not possible, no cleaning'}`); } // We still update the value in Homebridge. If we are calling the changed method is because we want to change it. this.services.pause.getCharacteristic(Characteristic.On).updateValue(isCleaning); } } changedCharging(isCharging) { const isNewValue = this.isNewValue('charging', isCharging); if (isNewValue) { this.log.info(`INF changedCharging | ${this.model} | Charging is ${isCharging ? 'active' : 'cancelled'}`); } // We still update the value in Homebridge. If we are calling the changed method is because we want to change it. this.services.battery.getCharacteristic(Characteristic.ChargingState).updateValue(isCharging ? Characteristic.ChargingState.CHARGING : Characteristic.ChargingState.NOT_CHARGING); if (this.config.dock) { if (isNewValue) { const msg = isCharging ? 'Robot was docked' : 'Robot not anymore in dock'; this.log.info(`INF changedCharging | ${this.model} | ${msg}.`); } this.services.dock.getCharacteristic(Characteristic.OccupancyDetected).updateValue(isCharging); } } changedSpeed(speed) { const isNewValue = this.isNewValue('speed', speed); if (isNewValue) { this.log.info(`MON changedSpeed | ${this.model} | FanSpeed is now ${speed}%`); } const speedMode = this.findSpeedModeFromMiio(speed); if (typeof speedMode === "undefined") { this.log.warn(`WAR changedSpeed | ${this.model} | Speed was changed to ${speed}%, this speed is not supported`); } else { const { homekitTopLevel, name } = speedMode; if (isNewValue) { this.log.info(`INF changedSpeed | ${this.model} | Speed was changed to ${speed}% (${name}), for HomeKit ${homekitTopLevel}%`); } this.services.fan.getCharacteristic(Characteristic.RotationSpeed).updateValue(homekitTopLevel); } } changedBattery(level) { this.log.debug(`DEB changedBattery | ${this.model} | BatteryLevel ${level}%`); this.services.battery.getCharacteristic(Characteristic.BatteryLevel).updateValue(level); this.services.battery.getCharacteristic(Characteristic.StatusLowBattery).updateValue((level < 20) ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL); } async initializeDevice() { this.log.debug('DEB getDevice | Discovering vacuum cleaner'); const device = await miio.device({ address: this.config.ip, token: this.config.token, }); if (device.matches('type:vaccuum')) { this.device = device; this.model = this.device.miioModel; this.services.info.setCharacteristic(Characteristic.Model, this.model); this.log.info('STA getDevice | Connected to: %s', this.config.ip); this.log.info('STA getDevice | Model: ' + this.device.miioModel); this.log.info('STA getDevice | State: ' + this.device.property("state")); this.log.info('STA getDevice | FanSpeed: ' + this.device.property("fanSpeed")); this.log.info('STA getDevice | BatteryLevel: ' + this.device.property("batteryLevel")); if (this.config.autoroom) { await this.getRoomMap(); } try { const serial = await this.getSerialNumber(); this.services.info.setCharacteristic(Characteristic.SerialNumber, `${serial}`); this.log.info(`STA getDevice | Serialnumber: ${serial}`); } catch (err) { this.log.error(`ERR getDevice | get_serial_number | ${err}`); } try { const firmware = await this.getFirmware(); this.firmware = firmware; this.services.info.setCharacteristic(Characteristic.FirmwareRevision, `${firmware}`); this.log.info(`STA getDevice | Firmwareversion: ${firmware}`); } catch (err) { this.log.error(`ERR getDevice | miIO.info | ${err}`); } //Changed by Mai var minStep = this.findSpeedModes().speed.length == 6 ? 20 : 25; this.log.info(`INF initializeDevice | ${this.model} ${this.firmware} | Setting the minimal step for fan service to ${minStep}.`); this.services.fan.getCharacteristic(Characteristic.RotationSpeed).setProps({ minStep: minStep }).on('get', (cb) => callbackify(() => this.getSpeed(), cb)).on('set', (newState, cb) => callbackify(() => this.setSpeed(newState), cb)); this.device.on('errorChanged', (error) => this.changedError(error)); this.device.on('stateChanged', (state) => { this.log.info(`INF stateChanged | ${this.model} | New state: ${state.key}=${state.value}`); if (state.key === 'cleaning') { this.changedCleaning(state.value); this.changedPause(state.value); } else if (state.key === 'charging') { this.changedCharging(state.value); } else if (state.key === 'fanSpeed') { this.changedSpeed(state.value); } else if (state.key === 'batteryLevel') { this.changedBattery(state.value); } else { this.log.WARN(`WRN stateChanged | ${this.model} | Not supported stateChanged event: ${state.key}=${state.value}`); } }); await this.getState(); // Refresh the state every 30s so miio maintains a fresh connection (or recovers connection if lost until we fix https://github.com/nicoh88/homebridge-xiaomi-roborock-vacuum/issues/81) clearInterval(this.getStateInterval); //Changed by Mai this.getStateInterval = setInterval(() => this.device.poll(false), GET_STATE_INTERVAL_MS); } else { const model = (device || {}).miioModel; this.log.error(`ERR getDevice | Device "${model}" is not registered as a vacuum cleaner! If you think it should be, please open an issue at https://github.com/nicoh88/homebridge-xiaomi-roborock-vacuum/issues/new and provide this line.`); this.log.debug(device); device.destroy(); } } async connect() { if (this.connectingPromise === null) { // if already trying to connect, don't trigger yet another one this.connectingPromise = this.initializeDevice().catch((error) => { this.log.error(`ERR connect | miio.device, next try in 2 minutes | ${error}`); clearTimeout(this.connectRetry); // Using setTimeout instead of holding the promise. This way we'll keep retrying but not holding the other actions this.connectRetry = setTimeout(() => this.connect().catch(() => {}), 120000); throw error; }); } try { await this.connectingPromise; clearTimeout(this.connectRetry); } finally { this.connectingPromise = null; } } async ensureDevice(callingMethod) { try { if (!this.device) { const errMsg = `ERR ${callingMethod} | No vacuum cleaner is discovered yet.`; this.log.error(errMsg); throw new Error(errMsg); } // checking if the device has an open socket it will fail retrieving it if not // https://github.com/aholstenson/miio/blob/master/lib/network.js#L227 const socket = this.device.handle.api.parent.socket; this.log.debug(`DEB ensureDevice | ${this.model} | The socket is still on. Reusing it.`); } catch (err) { if (/destroyed/i.test(err.message) || /No vacuum cleaner is discovered yet/.test(err.message)) { this.log.info(`INF ensureDevice | ${this.model} | The socket was destroyed or not initialised, initialising the device`); await this.connect(); } else { this.log.error(err); throw err; } } } async getState() { try { await this.ensureDevice('getState'); const state = await this.device.state(); this.log.debug(`DEB getState | ${this.model} | State %j`, state); safeCall(state.cleaning, (cleaning) => this.changedCleaning(cleaning)); safeCall(state.charging, (charging) => this.changedCharging(charging)); safeCall(state.fanSpeed, (fanSpeed) => this.changedSpeed(fanSpeed)); safeCall(state.batteryLevel, (batteryLevel) => this.changedBattery(batteryLevel)); safeCall(state.cleaning, (cleaning) => this.changedPause(cleaning)); if (this.config.waterBox) { safeCall(state['water_box_mode'], (waterBoxMode) => this.changedWaterSpeed(waterBoxMode)); } // No need to throw the error at this point. This are just warnings like (https://github.com/nicoh88/homebridge-xiaomi-roborock-vacuum/issues/91) safeCall(state.error, (error) => this.changedError(error)); } catch (err) { this.log.error(`ERR getState | %j`, err); } } async getSerialNumber() { await this.ensureDevice('getSerialNumber'); try { const serial = await this.device.call('get_serial_number'); this.log.info(`INF getSerialNumber | ${this.model} | Serial Number is ${serial[0].serial_number}`); return serial[0].serial_number; } catch (err) { this.log.error(`ERR getSerialNumber | Failed getting the firmware version.`, err); throw err; } } async getFirmware() { await this.ensureDevice('getFirmware'); try { const firmware = await this.device.call('miIO.info'); this.log.info(`INF getFirmware | ${this.model} | Firmwareversion is ${firmware.fw_ver}`); return firmware.fw_ver; } catch (err) { this.log.error(`ERR getFirmware | Failed getting the firmware version.`, err); throw err; } } get isCleaning() { const status = this.device.property('state'); return XiaomiRoborockVacuum.cleaningStatuses.includes(status); } get isGoingToTarget() { const status = this.device.property('state'); return 'unknown-16' === status; } //Changed by Mai get isInPause() { const status = this.device.property('state'); return 'paused' === status; } async getCleaning() { await this.ensureDevice('getCleaning'); try { const isCleaning = this.isCleaning this.log.info(`INF getCleaning | ${this.model} | Cleaning is ${isCleaning}`); return isCleaning; } catch (err) { this.log.error(`ERR getCleaning | Failed getting the cleaning status.`, err); throw err; } } //Changed by Mai async setCleaning(state) { await this.ensureDevice('setCleaning'); this.log.info(`ACT setCleaning | ${this.model} | Set cleaning to ${state}}`); try { if (state && !this.isCleaning) { // Start cleaning if (this.roomsToClean.length > 0) { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_segment_clean', this.roomsToClean, refreshState); this.log.info(`ACT setCleaning | ${this.model} | Start room cleaning for rooms ${this.roomsToClean}, the device is in state ${this.device.property('state')}`); } else { await this.device.activateCleaning(); this.log.info(`ACT setCleaning | ${this.model} | Start full cleaning, the device is in state ${this.device.property('state')}`); } return; } else if (!state && (this.isCleaning || this.isInPause)) { // Stop cleaning await this.activateCharging(); // Charging works for 1st, not for 2nd this.log.info(`ACT setCleaning | ${this.model} | Stop cleaning and go to charge, the device is in state ${this.device.property('state')}`); //All rooms are removed from the list this.roomsToClean.length = 0; return; } } catch (err) { this.log.error(`ERR setCleaning | ${this.model} | Failed to set cleaning to ${state}`, err); throw err; } this.log.error(`ERR setCleaning | ${this.model} |Cannot set cleaning to ${state} when current state is ${this.device.property('state')}!`); // throw new Error('Cannot start or stop cleaning due to the current state!'); } //Changed by Mai async getCleaningRoom(room) { await this.ensureDevice('getCleaningRoom'); return this.roomsToClean.includes(room); } //Changed by Mai async setCleaningRoom(state, room) { await this.ensureDevice('setCleaning'); try { if (state && !this.isCleaning && !this.isInPause) { this.log.info(`ACT setCleaning | ${this.model} | Add room ${room} to the list of rooms to be cleaned.`); if (!this.roomsToClean.includes(room)) { this.roomsToClean.push(room); } return; } else if (!state && !this.isCleaning && !this.isInPause) { this.log.info(`ACT setCleaning | ${this.model} | Remove room ${room} to the list of rooms to be cleaned.`); if (this.roomsToClean.includes(room)) { const index = this.roomsToClean.indexOf(room); if (index > -1) { this.roomsToClean.splice(index, 1); } } return; } } catch (err) { this.log.error(`ERR setCleaning | ${this.model} | Failed to set cleaning to ${state} for room ${room}`, err); throw err; } this.log.warn(`WRN setCleaning | ${this.model} | Cannot set cleaning state ${state} for room ${room} when the robot is in state ${this.device.property('state')}`); throw new Error(`WRN setCleaning | ${this.model} | Cannot set cleaning state ${state} for room ${room} when the robot is in state ${this.device.property('state')}`); } //Changed by Mai async getGotoTarget() { await this.ensureDevice('getGotoTarget'); this.log.info('INF getGotoTarget | ' + this.model + ' | Going to target ' + this.isGoingToTarget); return this.isGoingToTarget; } //changed by Mai async setGotoTarget(state, target) { await this.ensureDevice('setGotoTarget'); this.log.info('INF | setGotoTarget | ' + this.model + ' | Go to target ' + target + ' is set to ' + state); if (state) { if (this.device.property('state') === 'charging' && this.config.gotoTarget) { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_goto_target', target, refreshState); this.log.info(`INF setGotoTarget | ${this.model} | Start going to target ${target}`); } else { this.log.warn('WARN | setGotoTarget | ' + this.model + ' | Device is not in charging state or target not defined.'); throw new Error('Device is not in charging state or target not defined.'); } } else { this.log.debug('DEB | setGotoTarget | ' + this.model + ' | Go back to charging station.'); if (this.device.property('state') === 'waiting') { await this.activateCharging(); this.log.info(`INF setGotoTarget | ${this.model} | Going back to charging station`); } else if (this.isGoingToTarget) { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_pause', [], refreshState); this.log.info(`INF setGotoTarget | ${this.model} | Pause going to target`); await this.activateCharging(); this.log.info(`INF setGotoTarget | ${this.model} | Going back to charging station`); } else { this.log.warn('WARN | setGotoTarget | ' + this.model + ' | Not able to return to dock because device is on the way to target.'); throw new Error('Not able to return to dock because device is on the way to target.'); } } } async setCleaningZone(state, zone) { await this.ensureDevice('setCleaning'); try { if (state && !this.isCleaning) { // Start cleaning this.log.info(`ACT setCleaning | ${this.model} | Start cleaning Zone ${zone}, not charging.`); const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_zoned_clean', [zone], refreshState); } else if (!state) { // Stop cleaning this.log.info(`ACT setCleaning | ${this.model} | Stop cleaning and go to charge.`); await this.activateCharging(); } } catch (err) { this.log.error(`ERR setCleaning | ${this.model} | Failed to set cleaning to ${state}`, err); throw err; } } async activateCharging() { await this.ensureDevice('activateCharging'); try { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_stop', [], refreshState); // Wait one second before calling go to charge await new Promise(resolve => setTimeout(resolve, 1000)); const changeResponse = await this.device.call('app_charge', [], refreshState); if (!(changeResponse && changeResponse[0] === 'ok')) { throw new Error('Failed to go to change'); } } catch (err) { this.log.error(`ERR setCharging | ${this.model} | Failed to go charging.`, err); throw err; } } async getRoomMap() { await this.ensureDevice('getRoomMap'); try { const map = await this.device.call('get_room_mapping'); this.log.info(`INF getRoomMap | ${this.model} | Map is ${map}`); for (let val of map) { this.createRoom(val[0], val[1]); } } catch (err) { this.log.error(`ERR getRoomMap | Failed getting the Room Map.`, err); throw err; } } createRoom(roomId, roomName) { this.log.info(`INF createRoom | ${this.model} | Room ${roomName} (${roomId})`); this.services[roomName] = new Service.Switch(`${this.config.cleanword} ${roomName}`, 'roomService' + roomId); this.services[roomName].getCharacteristic(Characteristic.On).on('get', (cb) => callbackify(() => this.getCleaningRoom(roomId), cb)).on('set', (newState, cb) => callbackify(() => this.setCleaningRoom(newState, roomId), cb)).on('change', (oldState, newState) => { this.changedPause(newState); }); } createZone(zoneName, zoneParams) { this.log.info(`INF createRoom | ${this.model} | Zone ${zoneName} (${zoneParams})`); this.services[zoneName] = new Service.Switch(`${this.config.cleanword} ${zoneName}`, 'zoneCleaning' + zoneName); this.services[zoneName].getCharacteristic(Characteristic.On).on('get', (cb) => callbackify(() => this.getCleaning(), cb)).on('set', (newState, cb) => callbackify(() => this.setCleaningZone(newState, zoneParams), cb)).on('change', (oldState, newState) => { this.changedPause(newState); }); } findSpeedModes() { return (MODELS[this.model] || []).reduce((acc, option) => { if (option.firmware) { const [, cleanFirmware] = (this.firmware || '').match(/^(\d+\.\d+\.\d+)/) || []; return semver.satisfies(cleanFirmware, option.firmware) ? option : acc; } else { return option; } }, MODELS.default); } findSpeedModeFromMiio(speed) { // Get the speed modes for this model const speedModes = this.findSpeedModes().speed; // Find speed mode that matches the miLevel return speedModes.find((mode) => mode.miLevel === speed); } async getSpeed() { await this.ensureDevice('getSpeed'); const speed = this.device.property('fanSpeed'); this.log.debug(`DEB getSpeed | ${this.model} | Fanspeed is ${speed} over miIO. Converting to HomeKit`) const { homekitTopLevel, name } = this.findSpeedModeFromMiio(speed); this.log.info(`INF getSpeed | ${this.model} | Fanspeed is ${speed} over miIO "${name}" > HomeKit speed ${homekitTopLevel}%`); return homekitTopLevel || 0; } async setSpeed(speed) { await this.ensureDevice('setSpeed'); this.log.debug(`ACT setSpeed | ${this.model} | Speed got ${speed}% over HomeKit > CLEANUP.`); // Get the speed modes for this model const speedModes = this.findSpeedModes().speed; // gen1 has maximum of 91%, so anything over that won't work. Getting safety maximum. const safeSpeed = Math.min(parseInt(speed), speedModes[speedModes.length - 1].homekitTopLevel); // Find the minimum homekitTopLevel that matches the desired speed const { miLevel, name } = speedModes.find((mode) => safeSpeed <= mode.homekitTopLevel); this.log.info(`ACT setSpeed | ${this.model} | FanSpeed set to ${miLevel} over miIO for "${name}".`); if (miLevel === 0) { this.log.debug(`DEB setSpeed | ${this.model} | FanSpeed is 0 => Calling setCleaning(false) instead of changing the fan speed`); await this.setCleaning(false); } else { await this.device.changeFanSpeed(miLevel); } } findWaterSpeedModeFromMiio(speed) { // Get the speed modes for this model const speedModes = this.findSpeedModes().waterspeed || []; // Find speed mode that matches the miLevel return speedModes.find((mode) => mode.miLevel === speed); } async getWaterSpeedInDevice() { // From https://github.com/marcelrv/XiaomiRobotVacuumProtocol/blob/master/water_box_custom_mode.md const response = await this.device.call('get_water_box_custom_mode', [], { refresh: ['water_box_mode'] }); // From https://github.com/nicoh88/miio/blob/master/lib/devices/vacuum.js#L11-L18 const [waterMode] = response || []; if (typeof waterMode === undefined) { this.log.error(response); throw new Error(`Failed to get the water_box_mode`); } return waterMode; } async getWaterSpeed() { await this.ensureDevice('getWaterSpeed'); const speed = await this.getWaterSpeedInDevice(); this.log.info(`INF getWaterSpeed | ${this.model} | WaterBoxMode is ${speed} over miIO. Converting to HomeKit`) const waterSpeed = this.findWaterSpeedModeFromMiio(speed); let homekitValue = 0 if (waterSpeed) { const { homekitTopLevel, name } = waterSpeed this.log.info(`INF getWaterSpeed | ${this.model} | WaterBoxMode is ${speed} over miIO "${name}" > HomeKit speed ${homekitTopLevel}%`); homekitValue = homekitTopLevel || 0; } this.services.waterBox.getCharacteristic(Characteristic.On).updateValue(homekitValue > 0); return homekitValue; } async setWaterSpeed(speed) { await this.ensureDevice('setWaterSpeed'); this.log.debug(`ACT setWaterSpeed | ${this.model} | Speed got ${speed}% over HomeKit > CLEANUP.`); // Get the speed modes for this model const speedModes = this.findSpeedModes().waterspeed || []; // If the robot does not support water-mode cleaning if (speedModes.length === 0) { this.log.info(`INF setWaterSpeed | ${this.model} | Model does not support the water mode`); return; } // gen1 has maximum of 91%, so anything over that won't work. Getting safety maximum. const safeSpeed = Math.min(parseInt(speed), speedModes[speedModes.length - 1].homekitTopLevel); // Find the minimum homekitTopLevel that matches the desired speed const { miLevel, name } = speedModes.find((mode) => safeSpeed <= mode.homekitTopLevel); this.log.info(`ACT setWaterSpeed | ${this.model} | WaterBoxMode set to ${miLevel} over miIO for "${name}".`); // From https://github.com/marcelrv/XiaomiRobotVacuumProtocol/blob/master/water_box_custom_mode.md const response = await this.device.call('set_water_box_custom_mode', [miLevel], { refresh: ['water_box_mode'] }); // From https://github.com/nicoh88/miio/blob/master/lib/devices/vacuum.js#L11-L18 if (response !== 0 && response[0] !== 'ok') { this.log.error(response); throw new Error(`Failed to set the water_box_mode to ${miLevel}`); } } changedWaterSpeed(speed) { this.log.info(`MON changedWaterSpeed | ${this.model} | WaterBoxMode is now ${speed}%`); const speedMode = this.findWaterSpeedModeFromMiio(speed); if (typeof speedMode === "undefined") { this.log.warn(`WAR changedWaterSpeed | ${this.model} | Speed was changed to ${speed}%, this speed is not supported`); } else { const { homekitTopLevel, name } = speedMode; this.log.info(`INF changedWaterSpeed | ${this.model} | Speed was changed to ${speed}% (${name}), for HomeKit ${homekitTopLevel}%`); this.services.waterBox.getCharacteristic(Characteristic.RotationSpeed).updateValue(homekitTopLevel); this.services.waterBox.getCharacteristic(Characteristic.On).updateValue(homekitTopLevel > 0) } } async getPauseState() { await this.ensureDevice('getPauseState'); try { const isCleaning = this.isCleaning this.log.info(`INF getPauseState | ${this.model} | Pause possible is ${isCleaning}`); return isCleaning; } catch (err) { this.log.error(`ERR getPauseState | ${this.model} | Failed getting the cleaning status.`, err); } } //Method changed by Mai async setPauseState(state) { await this.ensureDevice('setPauseState'); try { if (state && this.isInPause) { if (this.roomsToClean.length > 0) { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('resume_segment_clean', [], refreshState); this.log.info(`INF setPauseState | Resume room cleaning, and the device is in state ${this.device.property('state')}`); } else { await this.device.activateCleaning(); this.log.info(`INF setPauseState | Resume normal cleaning, and the device is in state ${this.device.property('state')}`); } return; } else if (!state && this.isCleaning) { const refreshState = { refresh: ['state'], refreshDelay: 1000 }; await this.device.call('app_pause', [], refreshState); this.log.info(`INF setPauseState | Sent pause command to the robot, and the device is in state ${this.device.property('state')}`); return; } } catch (err) { this.log.error(`ERR setPauseState | ${this.model} | Failed updating pause state ${state}.`, err); } this.log.error(`ERR setPauseState | Cannot set pause state to ${state} due to current state ${this.device.property('state')}!`); throw new Error('Cannot set pause state to due to current state!'); } async getCharging() { await this.ensureDevice('getCharging'); // From https://github.com/aholstenson/miio/blob/master/lib/devices/vacuum.js#L65 const status = this.device.property('state'); this.log.info(`INF getCharging | ${this.model} | Charging is ${status === "charging"} (Status is ${status})`); return (status === "charging") ? Characteristic.ChargingState.CHARGING : Characteristic.ChargingState.NOT_CHARGING; } async getDocked() { await this.ensureDevice('getDocked'); // From https://github.com/aholstenson/miio/blob/master/lib/devices/vacuum.js#L65 const status = this.device.property('state'); this.log.info(`INF getDocked | ${this.model} | Robot Docked is ${status === 'charging'} (Status is ${status})`); return status === "charging"; } async getBattery() { await this.ensureDevice('getBattery'); // https://github.com/aholstenson/miio/blob/master/lib/devices/vacuum.js#L90 this.log.info(`INF getBattery | ${this.model} | Batterylevel is ${this.device.property('batteryLevel')}%`); return this.device.property('batteryLevel'); } async getBatteryLow() { await this.ensureDevice('getBatteryLow'); // https://github.com/aholstenson/miio/blob/master/lib/devices/vacuum.js#L90 this.log.info(`INF getBatteryLow | ${this.model} | Batterylevel is ${this.device.property('batteryLevel')}%`); return (this.device.property('batteryLevel') < 20) ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL; } async identify(callback) { await this.ensureDevice('identify'); this.log.info(`ACT identify | ${this.model} | Find me - Hello!`); try { await this.device.find(); callback(); } catch (err) { this.log.error(`ERR identify | ${this.model} | `, err); callback(err); } } getServices() { if (this.config.delay) sleep(5000); this.log.debug(`DEB getServices | ${this.model}`); return Object.keys(this.services).map((key) => this.services[key]); } // CONSUMABLE / CARE async getCareSensors() { await this.ensureDevice('getCareSensors'); // 30h = sensor_dirty_time const lifetime = 108000; const lifetimepercent = this.device.property("sensorDirtyTime") / lifetime * 100; this.log.info(`INF getCareSensors | ${this.model} | Sensors dirtytime is ${this.device.property("sensorDirtyTime")} seconds / ${lifetimepercent.toFixed(2)}%.`); return lifetimepercent; } async getCareFilter() { await this.ensureDevice('getCareFilter'); // 150h = filter_work_time const lifetime = 540000; const lifetimepercent = this.device.property("filterWorkTime") / lifetime * 100; this.log.info(`INF getCareFilter | ${this.model} | Filter worktime is ${this.device.property("filterWorkTime")} seconds / ${lifetimepercent.toFixed(2)}%.`); return lifetimepercent; } async getCareSideBrush() { await this.ensureDevice('getCareSideBrush'); // 200h = side_brush_work_time const lifetime = 720000; const lifetimepercent = this.device.property("sideBrushWorkTime") / lifetime * 100; this.log.info(`INF getCareSideBrush | ${this.model} | Sidebrush worktime is ${this.device.property("sideBrushWorkTime")} seconds / ${lifetimepercent.toFixed(2)}%.`); return lifetimepercent; } async getCareMainBrush() { await this.ensureDevice('getCareMainBrush'); // 300h = main_brush_work_time const lifetime = 1080000; const lifetimepercent = this.device.property("mainBrushWorkTime") / lifetime * 100; this.log.info(`INF getCareMainBrush | ${this.model} | Mainbrush worktime is ${this.device.property("mainBrushWorkTime")} seconds / ${lifetimepercent.toFixed(2)}%.`); return lifetimepercent; } }