matterbridge-aeg-robot
Version:
A Matterbridge plugin that connects AEG RX 9 / Electrolux Pure i9 robot vacuums to the Matter smart home ecosystem.
245 lines • 9.74 kB
JavaScript
// Matterbridge plugin for AEG RX9 / Electrolux Pure i9 robot vacuum
// Copyright © 2025 Alexander Thoukydides
import { AEGApplianceRX9Log } from './aeg-appliance-log-rx9.js';
import { formatList, logError, MS } from './utils.js';
import { RX9BatteryStatus, RX9Dustbin, RX9RobotStatus } from './aegapi-rx9-types.js';
import EventEmitter from 'events';
import { PeriodicOp } from './periodic-op.js';
import { AEGApplianceRX9CtrlActivity } from './aeg-appliance-rx9-ctrl-activity.js';
;
// Timings for polling the status
const POLL_INTERVAL_RAPID = 1 * MS;
const POLL_TIMEOUT_MULTIPLE = 2;
const POLL_TIMEOUT_OFFSET = 10 * MS;
// An AEG RX 9 / Electrolux Pure i9 robot manager
export class AEGApplianceRX9 extends EventEmitter {
log;
config;
api;
// Control the robot
setActivity;
// Static information
// ... from getAppliances
applianceId;
applianceName;
applianceType;
created;
// ... from getApplianceInfo
brand;
colour;
deviceType;
model;
pnc;
serialNumber;
variant;
// ... from getApplianceState
capabilities;
platform;
// Dynamic information about the robot
state = {
connected: false,
enabled: false,
batteryStatus: RX9BatteryStatus.Dead,
dustbinStatus: RX9Dustbin.Unknown,
firmwareVersion: '',
robotStatus: RX9RobotStatus.Error,
fauxStatus: RX9RobotStatus.Error,
messages: [],
isDocked: false,
isCharging: false
};
emittedState = {};
// Messages about the robot
emittedMessages = new Set();
// Periodic API polling
poll;
// Create a new robot manager
constructor(log, config, api, appliance, info, state) {
super({ captureRejections: true });
this.log = log;
this.config = config;
this.api = api;
super.on('error', err => { logError(this.log, 'Event', err); });
// Initialise static information about the robot
// ... from getAppliances
this.applianceId = appliance.applianceId;
this.applianceName = appliance.applianceName;
this.applianceType = appliance.applianceType;
this.created = appliance.created;
// ... from getApplianceInfo
const { applianceInfo } = info;
this.brand = applianceInfo.brand;
this.colour = applianceInfo.colour;
this.deviceType = applianceInfo.deviceType;
this.model = applianceInfo.model;
this.pnc = applianceInfo.pnc;
this.serialNumber = applianceInfo.serialNumber;
this.variant = applianceInfo.variant;
// ... from getApplianceState
const { reported } = state.properties;
this.platform = reported.platform;
this.capabilities = Object.keys(reported.capabilities);
// Initialise dynamic information
this.updateFromApplianceState(state);
// Allow the robot to be controlled
this.setActivity = new AEGApplianceRX9CtrlActivity(this).makeSetter();
// Start logging information about this robot
new AEGApplianceRX9Log(this);
}
// Start polling the appliance
async start() {
if (this.poll)
return;
// Ensure that events are generated for the initial state
this.emittedState = {};
this.emittedMessages.clear();
// Start polling the status
this.poll = new PeriodicOp(this.log, {
name: 'Appliance state',
interval: this.config.pollIntervalSeconds * MS,
intervalRapid: POLL_INTERVAL_RAPID,
timeout: this.config.pollIntervalSeconds * MS * POLL_TIMEOUT_MULTIPLE + POLL_TIMEOUT_OFFSET,
onOp: this.pollApplianceState.bind(this),
onError: this.pollError.bind(this)
});
return Promise.resolve();
}
// Stop polling the appliance
async stop() {
await this.poll?.stop();
this.poll = undefined;
}
// Periodically poll the appliance state
async pollApplianceState() {
const state = await this.api.getApplianceState();
this.updateFromApplianceState(state);
this.updateDerivedAndEmit();
}
// Handle a status update for a periodic action
pollError(err) {
this.state.apiError = err;
this.updateDerivedAndEmit();
}
// Describe this robot
toString() {
const { brand, model, applianceName, applianceId } = this;
return `${brand} ${model} '${applianceName}' (Product ID ${applianceId})`;
}
// Update dynamic robot state
updateFromApplianceState(state) {
// Extract the relevant information
const { reported } = state.properties;
this.updateStatus({
connected: state.connectionState === 'Connected',
enabled: state.status === 'enabled',
batteryStatus: reported.batteryStatus,
dustbinStatus: reported.dustbinStatus,
firmwareVersion: reported.firmwareVersion,
robotStatus: reported.robotStatus,
messages: reported.messageList.messages,
ecoMode: 'ecoMode' in reported ? reported.ecoMode : undefined,
powerMode: 'powerMode' in reported ? reported.powerMode : undefined
});
}
// Updated derived values and emit changes
updateDerivedAndEmit() {
this.updateDerived();
this.emit('preEmitPatch', this.state);
this.emitMessages();
this.emitChangeEvents();
}
// Update derived values
updateDerived() {
const { robotStatus, batteryStatus, isDocked: wasDocked } = this.state;
const activityMap = {
// Docked Charging
[RX9RobotStatus.Cleaning]: [false, false],
[RX9RobotStatus.PausedCleaning]: [false, false],
[RX9RobotStatus.SpotCleaning]: [false, false],
[RX9RobotStatus.PausedSpotCleaning]: [false, false],
[RX9RobotStatus.Return]: [false, false],
[RX9RobotStatus.PausedReturn]: [false, false],
[RX9RobotStatus.ReturnForPitstop]: [false, false],
[RX9RobotStatus.PausedReturnForPitstop]: [false, false],
[RX9RobotStatus.Charging]: [true, true],
[RX9RobotStatus.Sleeping]: [null, false],
[RX9RobotStatus.Error]: [null, false],
[RX9RobotStatus.Pitstop]: [true, true],
[RX9RobotStatus.ManualSteering]: [false, false],
[RX9RobotStatus.FirmwareUpgrade]: [null, false]
};
const [isDockedStatus, isCharging] = activityMap[robotStatus];
// Use heuristics to determine whether docked state when unspecified
const isDockedInferred = batteryStatus === RX9BatteryStatus.FullyCharged ? true
: batteryStatus === RX9BatteryStatus.High ? wasDocked
: false;
const isDocked = isDockedStatus ?? isDockedInferred;
// Update the status
this.updateStatus({ isDocked, isCharging, fauxStatus: robotStatus });
}
// Apply a partial update to the robot status
updateStatus(update) {
Object.assign(this.state, update);
}
// Apply updates to the robot status and emit events for changes
emitChangeEvents() {
// Identify the values that have changed
const keys = Object.keys(this.state);
const changed = keys.filter(key => {
const a = this.state[key], b = this.emittedState[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.emittedState[key])}->${toText(this.state[key])}`);
this.log.debug(formatList(summary));
// Emit an event listing all of the keys that have changed
this.emit('changed', keys, this.state);
// Emit events for each change
changed.forEach(key => this.emit(key, this.state[key], this.emittedState[key]));
// Store a copy of the updated values
this.emittedState = { ...this.state };
}
// Emit events for any new messages
emitMessages() {
const { messages } = this.state;
// 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);
}
});
}
// Create a new AEG RX 9 / Electrolux Pure i9 robot manager
static async create(log, config, api, appliance) {
// Read the full appliance details
const info = await api.getApplianceInfo();
const state = await api.getApplianceState();
// Create the robot manager
return new AEGApplianceRX9(log, config, api, appliance, info, state);
}
}
//# sourceMappingURL=aeg-appliance-rx9.js.map