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
JavaScript
// 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