UNPKG

homebridge-aeg-robot

Version:

AEG RX9 / Electrolux Pure i9 robot vacuum plugin for Homebridge

263 lines 10.9 kB
// 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