UNPKG

matterbridge-dyson-robot

Version:

A Matterbridge plugin that connects Dyson robot vacuums and air treatment devices to the Matter smart home ecosystem via their local or cloud MQTT APIs.

168 lines 10.1 kB
// Matterbridge plugin for Dyson robot vacuum and air treatment devices // Copyright © 2025-2026 Alexander Thoukydides import { RvcCleanMode360, RvcRunMode360 } from './endpoint-360-behavior.js'; import { Dyson360State } from './dyson-360-types.js'; import { CN, CV, RI } from './logger-options.js'; import { ChangeToModeError, RvcOperationalStateError, SelectAreaError } from './error-360.js'; import { MS } from './utils.js'; const STATE_COLUMNS = ['Idle', 'Cleaning', 'ZoneClean', 'Mapping', 'Pause', 'Resume', 'GoHome']; const STATE_MAP = { [Dyson360State.MachineOff]: [true, undefined, undefined, undefined, undefined, undefined, undefined], [Dyson360State.FaultCallHelpline]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultContactHelpline]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultCritical]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultGettingInfo]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultLost]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultOnDock]: ['abort', 'START', 'START', undefined, undefined, undefined, true], [Dyson360State.FaultOnDockCharged]: ['abort', 'START', 'START', undefined, undefined, undefined, true], [Dyson360State.FaultOnDockCharging]: ['abort', 'START', 'START', undefined, undefined, undefined, true], [Dyson360State.FaultReplaceOnDock]: ['abort', 'START', 'START', undefined, undefined, undefined, undefined], [Dyson360State.FaultReturnToDock]: ['abort', 'START', 'START', undefined, undefined, undefined, undefined], [Dyson360State.FaultRunningDiagnostic]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FaultUserRecoverable]: ['abort', 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.FullCleanAbandoned]: [true, 'START', undefined, undefined, undefined, undefined, true], [Dyson360State.FullCleanAborted]: [true, 'START', undefined, undefined, undefined, undefined, true], [Dyson360State.FullCleanCharging]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.FullCleanDiscovering]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.FullCleanFinished]: [true, 'START', undefined, undefined, undefined, undefined, true], [Dyson360State.FullCleanInitiated]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.FullCleanNeedsCharge]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.FullCleanPaused]: ['ABORT', true, undefined, undefined, undefined, 'RESUME', 'ABORT'], [Dyson360State.FullCleanRunning]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.FullCleanTraversing]: ['ABORT', true, undefined, undefined, 'PAUSE', undefined, 'ABORT'], [Dyson360State.InactiveCharged]: [true, 'START', 'START', undefined, undefined, undefined, true], [Dyson360State.InactiveCharging]: [true, 'START', 'START', undefined, undefined, undefined, true], [Dyson360State.InactiveDischarging]: [true, 'START', 'START', undefined, undefined, undefined, 'ABORT'], [Dyson360State.MappingAborted]: [true, 'START', undefined, undefined, undefined, undefined, true], [Dyson360State.MappingCharging]: ['ABORT', 'START', undefined, true, 'PAUSE', undefined, 'ABORT'], [Dyson360State.MappingFinished]: [true, 'START', undefined, undefined, undefined, undefined, true], [Dyson360State.MappingInitiated]: ['ABORT', 'START', undefined, true, 'PAUSE', undefined, 'ABORT'], [Dyson360State.MappingNeedsCharge]: ['ABORT', 'START', undefined, true, 'PAUSE', undefined, 'ABORT'], [Dyson360State.MappingPaused]: ['ABORT', 'START', undefined, true, undefined, 'RESUME', 'ABORT'], [Dyson360State.MappingRunning]: ['ABORT', 'START', undefined, true, 'PAUSE', undefined, 'ABORT'] }; // Timeout waiting for the next update const UPDATE_TIMEOUT = 5 * MS; // 5 seconds // Attach command handlers to a Dyson robot vacuum device RVC endpoint export class Device360CommandHandlers { log; mqtt; endpoint; // Abort previous operations that are still in progress abort; // Create a new command handler constructor(log, mqtt, endpoint) { this.log = log; this.mqtt = mqtt; this.endpoint = endpoint; // Handle RVC Operational State Pause/Resume/GoHome commands const operationStateAction = async (target) => { if (!await this.setTarget(`${CN}RVC Operational State ${CV}${target}${RI}`, target)) { throw new RvcOperationalStateError.CommandInvalidInState(); } }; this.endpoint.setCommandHandler360('Pause', () => operationStateAction('Pause')); this.endpoint.setCommandHandler360('Resume', () => operationStateAction('Resume')); this.endpoint.setCommandHandler360('GoHome', () => operationStateAction('GoHome')); // Handle RVC Run Mode cluster ChangeToMode commands this.endpoint.setCommandHandler360('ChangeRunMode', async (newMode) => { const target = RvcRunMode360[newMode]; if (!await this.setTarget(`${CN}RVC Run Mode${RI} ChangeToMode ${formatEnumLog(RvcRunMode360, newMode)}`, target)) { throw new ChangeToModeError.InvalidInMode(); } }); } // Handle RVC Clean Mode cluster ChangeToMode commands attachCleanModeHandler(makePowerCommand) { this.endpoint.setCommandHandler360('ChangeCleanMode', async (newMode) => { const { description, command, condition } = makePowerCommand(newMode); this.log.info(`${CN}RVC Clean Mode${RI} ChangeToMode ${formatEnumLog(RvcCleanMode360, newMode)}${CV}${description}${RI}`); await this.issueCommandAndWaitForUpdate(`set power mode ${description}`, command, condition); }); } // Handle Service Area cluster SelectAreas commands attachSelectAreasHandler(makeCleaningProgramme, makeAreaName) { this.endpoint.setCommandHandler360('SelectAreas', async (newAreas) => { if (newAreas.length === 0) { // An empty list means clean everywhere if (!await this.setTarget(`${CN}ServiceArea${RI} SelectAreas everywhere`, 'Cleaning')) { throw new SelectAreaError.InvalidInMode(); } } else { const areaNames = newAreas.map(areaId => makeAreaName(areaId)); const description = `${CN}ServiceArea${RI} ${CV}SelectAreas${RI} [${areaNames.join(', ')}]`; // SelectWhileRunning is not supported if (!this.targetAction('ZoneClean')) { this.log.info(`${description} → not allowed in current state`); throw new SelectAreaError.InvalidInMode(); } // Publish a command to start the zone configured cleaning this.log.info(`${description}${CV}ZoneClean${RI}`); const cleaningProgramme = await makeCleaningProgramme(newAreas); await this.issueCommandAndWaitForUpdate('perform action ZoneClean', () => this.mqtt.commandAction('START', cleaningProgramme), () => true); } }); } // Perform a command and wait for a status update async issueCommandAndWaitForUpdate(description, command, condition) { try { // Make the operation abortable with a timeout this.abort?.abort(); this.abort = new AbortController(); const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(UPDATE_TIMEOUT)]); // Publish the command await command(); // Wait for an update to satisfy the condition while (!condition()) { await this.mqtt.onceAsync('status', signal); } } catch (cause) { // Identify the underlying error let err = cause instanceof Error ? cause : new Error(String(cause)); while (err.name === 'AbortError' && err.cause instanceof Error) err = err.cause; // Map the error type to a description const errMap = { AbortError: 'Aborted', TimeoutError: 'Timed out' }; const result = errMap[err.name] ?? 'Failed to'; this.log.warn(`${result} ${description}`); throw err; } } // Lookup the action required for a particular target targetAction(target) { const column = STATE_COLUMNS.indexOf(target); return STATE_MAP[this.mqtt.status.state][column]; } ; // Attempt to set a target state, returning false if not allowed async setTarget(description, target) { // Check whether the target is allowed or already satisfied const action = this.targetAction(target); if (action === undefined) { this.log.info(`${description} → not allowed in current state`); return false; } else if (action === true) { this.log.info(`${description} → no action required`); return true; } // Publish a command to change the robot state this.log.info(`${description}${CV}${action}${RI}`); const command = action.toUpperCase(); const isTargetAchieved = (action) => action === true || action === action?.toLowerCase(); await this.issueCommandAndWaitForUpdate(`perform action ${target}`, () => this.mqtt.commandAction(command), () => isTargetAchieved(this.targetAction(target))); return true; } ; } // Format an enum value for logging function formatEnumLog(enumMap, value) { const label = enumMap[value]; return `${CV}${label}${RI} (${CV}${value}${RI})`; } //# sourceMappingURL=dyson-device-360-commands.js.map