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.
130 lines • 7.99 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025 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 } from './error-360.js';
import { MS } from './utils.js';
const STATE_COLUMNS = ['Idle', 'Cleaning', 'Mapping', 'Pause', 'Resume', 'GoHome'];
const STATE_MAP = {
[Dyson360State.MachineOff]: [true, undefined, undefined, undefined, undefined, undefined],
[Dyson360State.FaultCallHelpline]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultContactHelpline]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultCritical]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultGettingInfo]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultLost]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultOnDock]: ['abort', 'START', undefined, undefined, undefined, true],
[Dyson360State.FaultOnDockCharged]: ['abort', 'START', undefined, undefined, undefined, true],
[Dyson360State.FaultOnDockCharging]: ['abort', 'START', undefined, undefined, undefined, true],
[Dyson360State.FaultReplaceOnDock]: ['abort', 'START', undefined, undefined, undefined, undefined],
[Dyson360State.FaultReturnToDock]: ['abort', 'START', undefined, undefined, undefined, undefined],
[Dyson360State.FaultRunningDiagnostic]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FaultUserRecoverable]: ['abort', 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.FullCleanAbandoned]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.FullCleanAborted]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.FullCleanCharging]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.FullCleanDiscovering]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.FullCleanFinished]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.FullCleanInitiated]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.FullCleanNeedsCharge]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.FullCleanPaused]: ['ABORT', true, undefined, undefined, 'RESUME', 'ABORT'],
[Dyson360State.FullCleanRunning]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.FullCleanTraversing]: ['ABORT', true, undefined, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.InactiveCharged]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.InactiveCharging]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.InactiveDischarging]: [true, 'START', undefined, undefined, undefined, 'ABORT'],
[Dyson360State.MappingAborted]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.MappingCharging]: ['ABORT', 'START', true, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.MappingFinished]: [true, 'START', undefined, undefined, undefined, true],
[Dyson360State.MappingInitiated]: ['ABORT', 'START', true, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.MappingNeedsCharge]: ['ABORT', 'START', true, 'PAUSE', undefined, 'ABORT'],
[Dyson360State.MappingPaused]: ['ABORT', 'START', true, undefined, 'RESUME', 'ABORT'],
[Dyson360State.MappingRunning]: ['ABORT', 'START', 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 function attachDevice360CommandHandlers(log, mqtt, endpoint, powerMap) {
const context = {};
// Perform a command and wait for a status update
const issueCommandAndWaitForUpdate = async (description, command, condition) => {
try {
// Make the operation abortable with a timeout
context.abort?.abort();
context.abort = new AbortController();
const signal = AbortSignal.any([context.abort.signal, AbortSignal.timeout(UPDATE_TIMEOUT)]);
// Publish the command
await command();
// Wait for an update to satisfy the condition
while (!condition()) {
await 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';
log.warn(`${result} ${description}`);
throw err;
}
};
// Lookup the action required for a particular target
const targetAction = (target) => {
const column = STATE_COLUMNS.indexOf(target);
return STATE_MAP[mqtt.status.state][column];
};
// Attempt to set a target state, returning false if not allowed
const setTarget = async (description, target) => {
// Check whether the target is allowed or already satisfied
const action = targetAction(target);
if (action === undefined) {
log.info(`${description} → not allowed in current state`);
return false;
}
else if (action === true) {
log.info(`${description} → no action required`);
return true;
}
// Publish a command to change the robot state
log.info(`${description} → ${CV}${action}${RI}`);
const command = action.toUpperCase();
const isTargetAchieved = (action) => action === true || action === action?.toLowerCase();
await issueCommandAndWaitForUpdate(`perform action ${target}`, () => mqtt.commandAction(command), () => isTargetAchieved(targetAction(target)));
return true;
};
// Handle RVC Operational State Pause/Resume/GoHome commands
const operationStateAction = async (target) => {
if (!await setTarget(`${CN}RVC Operational State ${CV}${target}${RI}`, target)) {
throw new RvcOperationalStateError.CommandInvalidInState();
}
};
endpoint.setCommandHandler360('Pause', () => operationStateAction('Pause'));
endpoint.setCommandHandler360('Resume', () => operationStateAction('Resume'));
endpoint.setCommandHandler360('GoHome', () => operationStateAction('GoHome'));
// Handle RVC Run Mode cluster ChangeToMode commands
endpoint.setCommandHandler360('ChangeRunMode', async (newMode) => {
const target = RvcRunMode360[newMode];
if (!await setTarget(`${CN}RVC Run Mode${RI} ChangeToMode ${formatEnumLog(RvcRunMode360, newMode)}`, target)) {
throw new ChangeToModeError.InvalidInMode();
}
});
// Reject all RVC Clean Mode cluster ChangeToMode commands
endpoint.setCommandHandler360('ChangeCleanMode', async (newMode) => {
const powerMode = powerMap(newMode);
log.info(`${CN}RVC Clean Mode${RI} ChangeToMode ${formatEnumLog(RvcCleanMode360, newMode)} → ${CV}${powerMode}${RI}`);
await issueCommandAndWaitForUpdate(`set power mode ${powerMode}`, () => mqtt.commandPower(powerMode), () => mqtt.status.defaultVacuumPowerMode === powerMode);
});
}
// 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