matterbridge-aeg-robot
Version:
A Matterbridge plugin that connects AEG RX 9 / Electrolux Pure i9 robot vacuums to the Matter smart home ecosystem.
131 lines • 6.69 kB
JavaScript
// Matterbridge plugin for AEG RX9 / Electrolux Pure i9 robot vacuum
// Copyright © 2025 Alexander Thoukydides
import { RX9RobotStatus } from './aegapi-rx9-types.js';
import { AEGApplianceRX9Ctrl } from './aeg-appliance-rx9-ctrl.js';
import { formatList, formatMilliseconds, MS, plural } from './utils.js';
import { setTimeout } from 'node:timers/promises';
import { CC, RR } from './logger.js';
const ACTIVITY = ['Clean', 'Stop', 'Pause', 'Resume', 'Home'];
const FAUX_STATUS_MAP = {
Cleaning: [true, 'Sleeping', 'PausedCleaning', true, 'Return'],
PausedCleaning: [true, 'Sleeping', true, 'Cleaning', 'Return'],
SpotCleaning: [true, 'Sleeping', 'PausedSpotCleaning', true, 'Return'],
PausedSpotCleaning: [true, 'Sleeping', true, 'SpotCleaning', 'Return'],
Return: ['Cleaning', 'Sleeping', 'PausedReturn', true, true],
PausedReturn: ['Cleaning', 'Sleeping', true, 'Return', 'Return'],
ReturnForPitstop: [true, 'Sleeping', 'PausedReturnForPitstop', true, 'Return'],
PausedReturnForPitstop: [true, 'Sleeping', true, 'ReturnForPitstop', 'Return'],
Charging: ['Cleaning', true, undefined, undefined, true],
Sleeping: ['Cleaning', true, undefined, undefined, 'Return'],
Error: ['Cleaning', 'Sleeping', undefined, undefined, 'Return'],
Pitstop: [true, 'Charging', undefined, undefined, 'Charging'],
ManualSteering: ['Cleaning', 'Sleeping', undefined, undefined, 'Return'],
FirmwareUpgrade: [undefined, undefined, undefined, undefined, undefined]
};
// Command(s) required to start an activity
const CLEANING_COMMAND_MAP = {
// Clean Stop Pause Resume Home
Cleaning: [[], ['stop'], ['pause'], [], ['home']],
PausedCleaning: [[], ['stop'], [], ['play'], ['home']],
SpotCleaning: [[], ['stop'], ['pause'], [], ['home']],
PausedSpotCleaning: [[], ['stop'], [], ['play'], ['home']],
Return: [['stop', 'play'], ['stop'], ['pause'], [], []],
PausedReturn: [['stop', 'play'], ['stop'], [], ['play'], ['home']],
ReturnForPitstop: [[], ['stop'], ['pause'], [], ['home']],
PausedReturnForPitstop: [[], ['stop'], [], ['play'], ['home']],
Charging: [['play'], [], undefined, undefined, []],
Sleeping: [['play'], [], undefined, undefined, ['home']],
Error: [['play'], ['stop'], undefined, undefined, ['home']],
Pitstop: [[], ['stop'], undefined, undefined, ['home']],
ManualSteering: [['stop', 'play'], ['stop'], undefined, undefined, ['home']],
FirmwareUpgrade: [undefined, undefined, undefined, undefined, undefined]
};
// Check that the mapping tables are consistent
const errors = [];
for (const status of Object.keys(FAUX_STATUS_MAP)) {
for (let column = 0; column < ACTIVITY.length; ++column) {
const fauxStatus = FAUX_STATUS_MAP[status][column];
const command = CLEANING_COMMAND_MAP[status][column];
const activity = ACTIVITY[column];
const description = `${activity} activity in ${status} state`;
switch (fauxStatus) {
case true:
if (!command || command.length)
errors.push(`${description} should have empty command list`);
break;
case undefined:
if (command)
errors.push(`${description} is invalid, so should have undefined command list`);
break;
default:
if (!command?.length)
errors.push(`${description} requires a non-empty command list`);
if (fauxStatus === status)
errors.push(`${description} cannot transition to the same state`);
}
}
}
if (errors.length)
throw new Error(`Inconsistent activity mapping tables:\n${errors.join('\n')}`);
// Minimum interval between successive commands
const COMMAND_INTERVAL = 5 * MS; // 5 seconds
// Robot controller for changing the activity
export class AEGApplianceRX9CtrlActivity extends AEGApplianceRX9Ctrl {
appliance;
// Time of the last successful command response
lastCommandTime = 0;
// Create a new robot controller for changing the name
constructor(appliance) {
super(appliance, 'activity');
this.appliance = appliance;
}
// Check whether the robot is (or can) perform the required activity
isTargetSet(target) {
const fauxStatus = this.mapLookup(FAUX_STATUS_MAP, target);
return typeof fauxStatus === 'string' ? false : fauxStatus;
}
// Attempt to set the requested state
async setTarget(target, signal) {
const description = this.description(target);
// Lookup the appropriate commands for the current state
const commands = this.mapLookup(CLEANING_COMMAND_MAP, target);
if (!commands)
throw new Error(`Target ${target} not valid in current state`);
// Sequence the command(s) to the robot
this.log.info(`Sending ${plural(commands.length, 'command', false)}`
+ ` ${formatList(commands.map(c => `${CC}${c}${RR}`))} to ${description}`);
for (const command of commands) {
// Ensure a minimum interval between commands
const delay = this.lastCommandTime + COMMAND_INTERVAL - Date.now();
if (0 < delay) {
this.log.debug(`Waiting ${formatMilliseconds(delay, 1)}s before sending next command`);
await setTimeout(delay, undefined, { signal });
}
// Send the next command
await this.api.sendCleaningCommand(command, signal);
this.lastCommandTime = Date.now();
}
}
// Override the status while a requested change is pending
preEmitPatch(target) {
const description = this.description(target);
const { robotStatus } = this.appliance.state;
// Predict and overwrite the target status
const fauxStatus = this.mapLookup(FAUX_STATUS_MAP, target);
switch (fauxStatus) {
case true: throw new Error(`Target ${target} achieved`);
case undefined: throw new Error(`Target ${target} not valid in current state`);
default:
this.log.debug(`Patching ${description} status: ${RX9RobotStatus[robotStatus]}->${fauxStatus}`);
this.appliance.state.fauxStatus = RX9RobotStatus[fauxStatus];
}
}
// Convert an activity name to a column index
mapLookup(map, target) {
const { robotStatus } = this.appliance.state;
const row = RX9RobotStatus[robotStatus];
const column = ACTIVITY.indexOf(target);
return map[row][column];
}
}
//# sourceMappingURL=aeg-appliance-rx9-ctrl-activity.js.map