homebridge-aeg-robot
Version:
AEG RX9 / Electrolux Pure i9 robot vacuum plugin for Homebridge
263 lines • 10.9 kB
JavaScript
// Homebridge plugin for AEG RX 9 / Electrolux Pure i9 robot vacuum
// Copyright © 2022-2023 Alexander Thoukydides
import { EventEmitter } from 'events';
import { AEGRobotCtrlActivity } from './aeg-robot-ctrl.js';
import { AEGRobotLog } from './aeg-robot-log.js';
import { Heartbeat } from './heartbeat.js';
import { formatList, logError, MS } from './utils.js';
import { PrefixLogger } from './logger.js';
import { RX9BatteryStatus, RX9Dustbin, RX9RobotStatus } from './aegapi-rx9-types.js';
// Simplified robot activities
export var SimpleActivity;
(function (SimpleActivity) {
SimpleActivity["Other"] = "Other";
SimpleActivity["Clean"] = "Clean";
SimpleActivity["Pitstop"] = "Pitstop";
SimpleActivity["Pause"] = "Pause";
SimpleActivity["Return"] = "Return";
})(SimpleActivity || (SimpleActivity = {}));
// An AEG RX 9 / Electrolux Pure i9 robot manager
export class AEGRobot extends EventEmitter {
account;
appliance;
// Configuration
config;
// A custom logger
log;
// Electrolux Group API for an AEG RX9.1 or RX9.2 robot vacuum cleaner
api;
// Control the robot
setActivity;
// Static information about the robot (mostly initialised asynchronously)
applianceId; // Product ID
pnc = ''; // Product Number Code
sn = ''; // Serial Number
brand = '';
model = '';
name = '';
// Dynamic information about the robot
status = {
hardware: '',
firmware: '',
capabilities: [],
enabled: false,
connected: false
};
emittedStatus = {};
// Messages about the robot
emittedMessages = new Set();
// Promise that is resolved by successful initialisation
readyPromise;
// Create a new robot manager
constructor(log, account, appliance) {
super({ captureRejections: true });
this.account = account;
this.appliance = appliance;
super.on('error', err => { logError(this.log, 'Event', err); });
// Construct a Logger and API for this robot
this.config = account.config;
this.log = new PrefixLogger(log, appliance.applianceName);
this.api = account.api.rx9API(appliance.applianceId);
// Initialise static information that is already known
this.applianceId = appliance.applianceId;
this.model = appliance.applianceType;
this.name = appliance.applianceName;
// Allow the robot to be controlled
this.setActivity = new AEGRobotCtrlActivity(this).makeSetter();
// Start logging information about this robot
new AEGRobotLog(this);
// Start asynchronous initialisation
this.readyPromise = this.init();
}
// Wait for the robot manager to initialise
async waitUntilReady() {
await this.readyPromise;
return this;
}
// Read the full static appliance details to complete initialisation
async init() {
try {
// Read the full appliance details
const info = await this.api.getApplianceInfo();
this.updateFromApplianceInfo(info);
const pollState = async () => {
const state = await this.api.getApplianceState();
this.updateFromApplianceState(state);
};
await pollState();
// Start polling the appliance state periodically
new Heartbeat(this.log, 'Appliance state', this.config.pollIntervals.statusSeconds * MS, pollState, (err) => { this.heartbeat(err); });
}
catch (err) {
logError(this.log, 'Appliance info', err);
}
}
// Describe this robot
toString() {
const bits = [
this.brand,
this.model,
this.name && `"${this.name}"`,
`(Product ID ${this.applianceId})`
];
return bits.filter(bit => bit.length).join(' ');
}
// Set static robot state
updateFromApplianceInfo(info) {
const { serialNumber, pnc, brand, model } = info.applianceInfo;
this.pnc = pnc;
this.sn = serialNumber;
this.brand = brand;
this.model += ` (${model})`;
this.emit('info');
}
// Update dynamic robot state
updateFromApplianceState(state) {
// Extract the relevant information
const { reported } = state.properties;
this.updateStatus({
enabled: state.status === 'enabled',
connected: state.connectionState === 'Connected',
capabilities: Object.keys(reported.capabilities),
hardware: reported.platform,
firmware: reported.firmwareVersion,
battery: reported.batteryStatus,
activity: reported.robotStatus,
dustbin: reported.dustbinStatus,
rawPower: 'powerMode' in reported ? reported.powerMode : undefined,
rawEco: 'ecoMode' in reported ? reported.ecoMode : undefined
});
// Extract any new messages
this.emitMessages(reported.messageList.messages);
// Generate derived state
this.updateDerivedAndEmit();
this.emit('appliance');
}
// Periodically poll the appliance state
async pollApplianceState() {
const state = await this.api.getApplianceState();
this.updateFromApplianceState(state);
}
// Handle a status update for a periodic action
heartbeat(err) {
this.status.isRobotError = err;
this.updateDerivedAndEmit();
}
// Update robot status that is derived from other information sources
updateDerivedAndEmit() {
this.updateDerived();
this.emit('preUpdate');
this.emitChangeEvents();
}
// Update derived values
updateDerived() {
const activityMap = {
// Activity Docked Charging Active
[RX9RobotStatus.Cleaning]: [SimpleActivity.Clean, false, false, true],
[RX9RobotStatus.PausedCleaning]: [SimpleActivity.Pause, false, false, false],
[RX9RobotStatus.SpotCleaning]: [SimpleActivity.Clean, false, false, true],
[RX9RobotStatus.PausedSpotCleaning]: [SimpleActivity.Pause, false, false, false],
[RX9RobotStatus.Return]: [SimpleActivity.Return, false, false, true],
[RX9RobotStatus.PausedReturn]: [SimpleActivity.Pause, false, false, false],
[RX9RobotStatus.ReturnForPitstop]: [SimpleActivity.Pitstop, false, false, true],
[RX9RobotStatus.PausedReturnForPitstop]: [SimpleActivity.Pause, false, false, false],
[RX9RobotStatus.Charging]: [SimpleActivity.Other, true, true, true],
[RX9RobotStatus.Sleeping]: [SimpleActivity.Other, null, false, true],
[RX9RobotStatus.Error]: [SimpleActivity.Other, null, false, false],
[RX9RobotStatus.Pitstop]: [SimpleActivity.Pitstop, true, true, true],
[RX9RobotStatus.ManualSteering]: [SimpleActivity.Other, false, false, false],
[RX9RobotStatus.FirmwareUpgrade]: [SimpleActivity.Other, null, false, false]
};
const [activity, isDocked, isCharging, isActive] = this.status.activity === undefined
? [SimpleActivity.Other] : activityMap[this.status.activity];
const isBusy = [SimpleActivity.Clean, SimpleActivity.Pitstop].includes(activity);
// Combine account and appliance errors
const isError = this.status.isRobotError;
// Any identified problem is treated as a fault
const isFault = isError !== undefined
|| !this.status.enabled
|| !this.status.connected
|| this.status.activity === RX9RobotStatus.Error
|| this.status.battery === RX9BatteryStatus.Dead
|| this.status.isDustbinEmpty === false;
// Update the status
this.updateStatus({
simpleActivity: activity,
isBatteryLow: this.status.battery !== undefined
&& this.status.battery <= RX9BatteryStatus.Low,
isCharging,
isDustbinEmpty: this.status.dustbin !== undefined
&& ![RX9Dustbin.Missing, RX9Dustbin.Full].includes(this.status.dustbin),
isDocked: isDocked ?? this.status.battery === RX9BatteryStatus.FullyCharged,
isActive: isActive && !isFault,
isBusy,
isError,
isFault,
power: isBusy ? this.status.rawPower : undefined,
eco: isBusy ? this.status.rawEco : undefined
});
}
// Apply a partial update to the robot status
updateStatus(update) {
Object.assign(this.status, update);
}
// Apply updates to the robot status and emit events for changes
emitChangeEvents() {
// Identify the values that have changed
const keys = Object.keys(this.status);
const changed = keys.filter(key => {
const a = this.status[key], b = this.emittedStatus[key];
if (Array.isArray(a) && Array.isArray(b)) {
return a.length !== b.length
|| a.some((element, index) => element !== b[index]);
}
else {
return a !== b;
}
});
if (!changed.length)
return;
// Log a summary of the changes
const toText = (value) => {
switch (typeof (value)) {
case 'undefined': return '?';
case 'string': return /[- <>:,]/.test(value) ? `"${value}"` : value;
case 'number': return value.toString();
case 'boolean': return value.toString();
default: return JSON.stringify(value);
}
};
const summary = changed.map(key => `${key}: ${toText(this.emittedStatus[key])}->${toText(this.status[key])}`);
this.log.debug(formatList(summary));
// Emit events for each change
changed.forEach(key => this.emit(key, this.status[key], this.emittedStatus[key]));
// Store a copy of the updated values
this.emittedStatus = { ...this.status };
}
// Emit events for any new messages
emitMessages(messages = []) {
// If there are no current messages then just flush the cache
if (!messages.length) {
this.emittedMessages.clear();
return;
}
// Emit events for any new messages
messages.forEach(message => {
const { id } = message;
if (!(this.emittedMessages.has(id))) {
this.emittedMessages.add(id);
this.emit('message', message);
}
});
}
on(event, listener) {
return super.on(event, listener);
}
once(event, listener) {
return super.once(event, listener);
}
emit(event, ...args) {
return super.emit(event, ...args);
}
}
//# sourceMappingURL=aeg-robot.js.map