homebridge-valetudo-xiaomi-vacuum
Version:
A Homebridge plugin for Xiaomi Vacuum Cleaner 1st gen (Mi Robot) and 2nd gen (Roborock S50), with Valetudo installed.
454 lines (386 loc) • 12.5 kB
JavaScript
const { sendJSONRequest } = require('./request');
const types = require('./types');
class VacuumValetudo {
constructor(log, config, statusCallback) {
const powerControl = config['power-control'];
this.ip = config.ip;
this.log = log;
this.authentication = config.authentication || null;
if (powerControl) {
const defaultSpeedValue = VacuumValetudo.getSpeedValue(powerControl['default-speed'] || 'balanced');
const highSpeedValue = VacuumValetudo.getSpeedValue(powerControl['high-speed'] || 'max');
this.powerControl = {
defaultSpeed: defaultSpeedValue,
highSpeed: highSpeedValue,
mop: powerControl['mop-enabled'] === true,
};
this.log.debug(`Setting power control: default speed - ${this.powerControl.defaultSpeed}, high speed - ${this.powerControl.highSpeed}, mop enabled - ${this.powerControl.mop}`);
}
this.current_status = null;
this.status_callbacks = [];
this.current_status_time = null;
this.status_timer = null;
this.idle_update_interval = 120000;
this.busy_update_interval = 10000;
this.status_callback = statusCallback;
if (!this.ip) {
throw new Error('You must provide an ip address of the vacuum cleaner.');
}
}
static statusUrl(ip) {
return `http://${ip}/api/v2/robot/state/attributes`;
}
static parseStatus(response) {
const simpleStatus = {};
response.forEach((value) => {
// eslint-disable-next-line no-underscore-dangle
switch (value.__class) {
case 'StatusStateAttribute':
simpleStatus.state = value.value;
simpleStatus.cleaning_mode = value.flag;
break;
case 'IntensityStateAttribute':
simpleStatus.fan_power = value.value;
break;
case 'BatteryStateAttribute':
simpleStatus.battery = value.level;
simpleStatus.battery_status = value.flag;
break;
default:
break;
}
});
return simpleStatus;
}
isHighSpeedMode(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(error, false);
return;
}
callback(null, status.fan_power === this.powerControl.highSpeed);
});
}
isMopMode(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(error, false);
return;
}
callback(null, status.fan_power === VacuumValetudo.SPEEDS.OFF);
});
}
setHighSpeedMode(on, callback) {
this.isHighSpeedMode((error, isOn) => {
if (error) {
callback(error);
return;
}
if (on == isOn) {
callback(null);
return;
}
if (on) {
this.setFanSpeed(this.powerControl.highSpeed, callback);
} else {
this.setFanSpeed(this.powerControl.defaultSpeed, callback);
}
});
}
setMopMode(on, callback) {
this.isMopMode((error, isOn) => {
if (error) {
callback(error);
return;
}
if (on && isOn) {
callback(null);
return;
}
if (!on && !isOn) {
callback(null);
return;
}
if (on) {
this.setFanSpeed(VacuumValetudo.SPEEDS.OFF, callback);
} else {
this.setFanSpeed(this.powerControl.defaultSpeed, callback);
}
});
}
async setFanSpeed(value, callback) {
this.log.debug(`Setting fan power to ${value}`);
try {
await sendJSONRequest({
url: `http://${this.ip}/api/v2/robot/capabilities/FanSpeedControlCapability/preset`, method: 'PUT', content: { name: value }, raw_response: true, authentication: this.authentication,
});
this.updateStatus(true);
callback(null);
} catch (e) {
this.log.error(`Failed to change fan power: ${e}`);
callback(e);
}
}
getBatteryLevel(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(error, null);
return;
}
callback(null, status.battery);
});
}
getChargingState(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(error);
} else if (status.battery_status === VacuumValetudo.BATTERY_STATE.CHARGING) {
callback(null, types.CHARGING_STATE.CHARGING);
} else if (status.battery_status === VacuumValetudo.BATTERY_STATE.DISCHARGING) {
callback(null, types.CHARGING_STATE.DISCHARGING);
} else {
callback(null, types.CHARGING_STATE.CHARGED);
}
});
}
async version(callback) {
try {
const response = await sendJSONRequest({ url: `http://${this.ip}/api/v2/valetudo/version`, authentication: this.authentication });
if (response != null) {
callback(null, response.release);
} else {
throw Error('Cannot get current version');
}
} catch (e) {
this.log.error(`Error parsing firmware info: ${e}`);
callback(e);
}
}
async doFind(callback) {
try {
await sendJSONRequest({
url: `http://${this.ip}/api/v2/robot/capabilities/LocateCapability`, method: 'PUT', content: { action: 'locate' }, raw_response: true, authentication: this.authentication,
});
callback();
} catch (e) {
this.log.error(`Failed to identify robot: ${e}`);
callback(e);
}
}
async goHome(callback) {
this.log.debug('Executing go home');
try {
await sendJSONRequest({
url: `http://${this.ip}/api/v2/robot/capabilities/BasicControlCapability`, method: 'PUT', content: { action: 'home' }, raw_response: true, authentication: this.authentication,
});
callback();
} catch (e) {
this.log.error(`Failed to execute go home: ${e}`);
callback(e);
} finally {
setTimeout(() => { this.updateStatus(true); }, 2000);
}
}
isGoingHome(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(new Error(`Error retrieving going home status: ${error}`));
return;
}
callback(null, status.state === VacuumValetudo.STATES.RETURNING);
});
}
async startCleaning(callback) {
this.log.debug('Executing start cleaning');
try {
await sendJSONRequest({
url: `http://${this.ip}/api/v2/robot/capabilities/BasicControlCapability`, method: 'PUT', content: { action: 'start' }, raw_response: true, authentication: this.authentication,
});
callback();
} catch (e) {
this.log.error(`Failed to start cleaning: ${e}`);
callback(e);
} finally {
setTimeout(() => { this.updateStatus(true); }, 2000);
}
}
async stopCleaning(callback) {
this.log.debug('Executing stop cleaning');
this.getStatus(true, async (err, status) => {
if (err) {
callback(err);
return;
}
if (status.state === VacuumValetudo.STATES.ERROR
|| status.state === VacuumValetudo.STATES.DOCKED
|| status.state === VacuumValetudo.STATES.IDLE
|| status.state === VacuumValetudo.STATES.RETURNING
|| status.state === VacuumValetudo.STATES.MANUAL_CONTROL
|| status.state === VacuumValetudo.STATES.MOVING) {
callback(new Error('Cannot stop cleaning in current state'));
}
try {
await sendJSONRequest({
url: `http://${this.ip}/api/v2/robot/capabilities/BasicControlCapability`, method: 'PUT', content: { action: 'stop' }, raw_response: true, authentication: this.authentication,
});
callback();
} catch (e) {
this.log.error(`Failed to stop cleaning: ${e}`);
callback(e);
} finally {
setTimeout(() => { this.updateStatus(true); }, 2000);
}
});
}
isCleaning(callback) {
this.getStatus(false, (error, status) => {
this.log.debug(`Is cleaning? error: ${error}, state: ${status !== null ? status.state : null}`);
if (error) {
callback(error);
return;
}
callback(null, status.state === VacuumValetudo.STATES.CLEANING);
});
}
async startSpotCleaning(callback) {
this.log.debug('Executing spot cleaning');
try {
await sendJSONRequest({ url: `http://${this.ip}/api/spot_clean`, method: 'PUT', raw_response: true, authentication: this.authentication });
callback();
} catch (e) {
this.log.error(`Failed to start spot cleaning: ${e}`);
callback(e);
} finally {
setTimeout(() => { this.updateStatus(true); }, 2000);
}
}
isSpotCleaning(callback) {
this.getStatus(false, (error, status) => {
if (error) {
callback(error);
return;
}
callback(null, status.cleaning_mode === VacuumValetudo.CLEANING_MODES.SPOT);
});
}
updateStatus(forced = false) {
this.log.debug('Updating vacuum status');
this.getStatus(forced, (err, status) => {
if (err) {
return;
}
try {
this.status_callback(status);
} catch (e) {
this.log.error(`status callback function errored out ${e}`);
}
});
}
updateInterval() {
if (this.current_status !== null) {
switch (this.current_status.state) {
case VacuumValetudo.STATES.DOCKED:
case VacuumValetudo.STATES.IDLE:
return this.idle_update_interval; // slow update interval for idle states
default:
break;
}
}
return this.busy_update_interval; // fast update interval for non-idle states
}
clearUpdateTimer() {
clearTimeout(this.status_timer);
}
setupUpdateTimer() {
this.status_timer = setTimeout(() => { this.updateStatus(true); }, this.updateInterval());
}
async getStatus(forced, callback) {
if (this.status_callbacks.length > 0) {
this.log.debug('Pushing status callback to queue - updating');
this.status_callbacks.push(callback);
return;
}
const now = Date.now();
if (!forced && this.current_status !== null
&& this.current_status_time !== null
&& (now - this.current_status_time < this.busy_update_interval)) {
this.log.debug('Returning cached status');
callback(null, this.current_status);
return;
}
this.clearUpdateTimer();
this.log.debug(`Executing update, forced: ${forced}`);
this.status_callbacks.push(callback);
try {
const response = await sendJSONRequest({
url: VacuumValetudo.statusUrl(this.ip),
authentication: this.authentication,
});
this.log.debug('Done executing update');
const status = VacuumValetudo.parseStatus(response);
this.current_status = status;
this.current_status_time = Date.now();
const callbacks = this.status_callbacks;
this.status_callbacks = [];
this.log.debug(`Calling ${callbacks.length} queued callbacks`);
callbacks.forEach((element) => {
element(null, status);
});
this.setupUpdateTimer();
} catch (e) {
this.log.error(`Error parsing current status info: ${e}`);
const callbacks = this.status_callbacks;
this.status_callbacks = [];
callbacks.forEach((element) => {
element(e, null);
});
this.setupUpdateTimer();
}
}
static getSpeedValue(preset) {
switch (preset) {
case 'min': return VacuumValetudo.SPEEDS.LOW;
case 'quiet': return VacuumValetudo.SPEEDS.LOW;
case 'low': return VacuumValetudo.SPEEDS.LOW;
case 'medium': return VacuumValetudo.SPEEDS.MEDIUM;
case 'balanced': return VacuumValetudo.SPEEDS.MEDIUM;
case 'max': return VacuumValetudo.SPEEDS.HIGH;
case 'turbo': return VacuumValetudo.SPEEDS.MAX;
case 'mop': return VacuumValetudo.SPEEDS.OFF;
default: throw Error(`Invalid power preset given: ${preset}`);
}
}
}
VacuumValetudo.STATES = {
ERROR: 'error',
DOCKED: 'docked',
IDLE: 'idle',
RETURNING: 'returning',
CLEANING: 'cleaning',
PAUSED: 'paused',
MANUAL_CONTROL: 'manual_control',
MOVING: 'moving',
};
VacuumValetudo.BATTERY_STATE = {
NONE: 'none',
CHARGING: 'charging',
DISCHARGING: 'discharging',
CHARGED: 'charged',
MAX: 'max',
};
VacuumValetudo.SPEEDS = {
OFF: 'off',
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
MAX: 'max',
};
VacuumValetudo.CLEANING_MODES = {
NONE: 'none',
ZONE: 'zone',
SECTION: 'section',
SPOT: 'spot',
TARGET: 'target',
RESUMABLE: 'resumable',
};
module.exports = { VacuumValetudo };