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.
211 lines • 9.09 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025-2026 Alexander Thoukydides
import { Behavior } from 'matterbridge/matter';
import { ClusterModel, FieldElement } from 'matterbridge/matter';
import { RvcOperationalState } from 'matterbridge/matter/clusters';
import { RvcCleanModeBehavior, RvcOperationalStateBehavior, RvcRunModeBehavior, ServiceAreaBehavior } from 'matterbridge/matter/behaviors';
import { ChangeToModeError, RvcOperationalStateError, SelectAreaError } from './error-360.js';
import { assertIsDefined, assertIsInstanceOf, formatList } from './utils.js';
import { logError } from './log-error.js';
import { isDeepStrictEqual } from 'util';
// Robot Vacuum Cleaner Run Mode cluster modes
export var RvcRunMode360;
(function (RvcRunMode360) {
RvcRunMode360[RvcRunMode360["Idle"] = 0] = "Idle";
RvcRunMode360[RvcRunMode360["Cleaning"] = 1] = "Cleaning";
RvcRunMode360[RvcRunMode360["Mapping"] = 2] = "Mapping";
})(RvcRunMode360 || (RvcRunMode360 = {}));
// Robot Vacuum Cleaner Clean Mode cluster modes
export var RvcCleanMode360;
(function (RvcCleanMode360) {
RvcCleanMode360[RvcCleanMode360["Quiet"] = 0] = "Quiet";
RvcCleanMode360[RvcCleanMode360["Quick"] = 1] = "Quick";
RvcCleanMode360[RvcCleanMode360["High"] = 2] = "High";
RvcCleanMode360[RvcCleanMode360["MaxBoost"] = 3] = "MaxBoost";
RvcCleanMode360[RvcCleanMode360["Auto"] = 4] = "Auto"; // Vis Nav: Auto
})(RvcCleanMode360 || (RvcCleanMode360 = {}));
// OperationalStatus manufacturer error
export const VENDOR_ERROR_360 = 0x80;
// Command handling behaviour for the endpoint
export class BehaviorDevice360 {
log;
// Registered command handlers
commands = {};
// Construct new command handling behaviour
constructor(log) {
this.log = log;
}
// Set a command handler
setCommandHandler(command, handler) {
if (this.commands[command])
throw new Error(`Handler already registered for command ${command}`);
this.commands[command] = handler;
}
// Execute a command handler
async executeCommand(command, ...args) {
const handler = this.commands[command];
if (!handler)
throw new Error(`${command} not implemented`);
await handler(...args);
}
}
export class Behavior360 extends Behavior {
static id = 'dyson-rvc';
}
// eslint-disable-next-line @typescript-eslint/no-namespace
(function (Behavior360) {
class State {
device;
}
Behavior360.State = State;
})(Behavior360 || (Behavior360 = {}));
// Implement command handlers for the RVC Run Mode cluster
export class RvcRunModeServer360 extends RvcRunModeBehavior {
// ChangeToMode command handler
async changeToMode({ newMode }) {
const { device } = this.agent.get(Behavior360).state;
const { log } = device;
try {
// Check whether it is a valid request
log.debug(`RVC Run Mode command: ChangeToMode ${newMode}...`);
const supported = this.state.supportedModes.some(({ mode }) => mode === newMode);
if (!supported)
throw new ChangeToModeError.UnsupportedMode;
// Attempt to change the mode
await device.executeCommand('ChangeRunMode', newMode);
// Success
log.debug(`RVC Run Mode command: ChangeToMode ${newMode} - OK`);
return ChangeToModeError.toResponse();
}
catch (err) {
logError(log, 'RVC Run Mode ChangeToMode', err);
return ChangeToModeError.toResponse(err);
}
}
}
// Implement command handlers for the RVC Clean Mode cluster
export class RvcCleanModeServer360 extends RvcCleanModeBehavior {
// ChangeToMode command handler
async changeToMode({ newMode }) {
const { device } = this.agent.get(Behavior360).state;
const { log } = device;
try {
// Check whether it is a valid request
log.debug(`RVC Clean Mode command: ChangeToMode ${newMode}...`);
const supported = this.state.supportedModes.some(({ mode }) => mode === newMode);
if (!supported)
throw new ChangeToModeError.UnsupportedMode;
// Attempt to change the mode
await device.executeCommand('ChangeCleanMode', newMode);
// Success
log.debug(`RVC Clean Mode command: ChangeToMode ${newMode} - OK`);
return ChangeToModeError.toResponse();
}
catch (err) {
logError(log, 'RVC Clean Mode ChangeToMode', err);
return ChangeToModeError.toResponse(err);
}
}
}
// Implement command handlers for the RVC Operational State cluster
export class RvcOperationalStateServer360 extends RvcOperationalStateBehavior {
static {
const schema = RvcOperationalStateServer360.schema;
assertIsInstanceOf(schema, ClusterModel);
// Add a manufacturer-specific ErrorState value
extendEnum(schema, 'ErrorStateEnum', [
FieldElement({
name: 'OtherError',
id: VENDOR_ERROR_360,
conformance: 'O',
description: 'The device has an error that is not covered by the Matter-defined error states'
})
]);
}
// Common command handler
async command(command, defaultErrorId) {
const { device } = this.agent.get(Behavior360).state;
const { log } = device;
try {
log.debug(`RVC Operational State command: ${command}...`);
await device.executeCommand(command);
log.debug(`RVC Operational State command: ${command} - OK`);
return RvcOperationalStateError.toResponse();
}
catch (err) {
logError(log, `RVC Operational State ${command}`, err);
return RvcOperationalStateError.toResponse(err, defaultErrorId);
}
}
// Pause command handler
pause() {
return this.command('Pause', RvcOperationalState.ErrorState.CommandInvalidInState);
}
// Resume command handler
resume() {
return this.command('Resume', RvcOperationalState.ErrorState.UnableToStartOrResume);
}
// GoHome command handler
goHome() {
return this.command('GoHome', RvcOperationalState.ErrorState.CommandInvalidInState);
}
}
// Implement command handlers for the Service Areas cluster
export class ServiceAreaServer360 extends ServiceAreaBehavior {
// SelectAreas command handler
async selectAreas({ newAreas }) {
const { device } = this.agent.get(Behavior360).state;
const { log } = device;
try {
// Remove any duplicated areas from the list
log.info(`Service Area command: SelectAreas ${formatList(newAreas.map(id => String(id)))}`);
newAreas = [...new Set(newAreas)];
// Check whether it is a valid request
const maps = new Set();
for (const area of newAreas) {
const supportedArea = this.state.supportedAreas.find(({ areaId }) => areaId === area);
if (!supportedArea)
throw new SelectAreaError.UnsupportedArea(`${area} is not a supported area`);
maps.add(supportedArea.mapId);
}
// If all areas are specified then treat it as an empty list
if (newAreas.length === this.state.supportedAreas.length)
newAreas = [];
else if (maps.size !== 1)
throw new SelectAreaError.InvalidSet('Areas must all be from the same map');
// Attempt to select the areas
await device.executeCommand('SelectAreas', newAreas);
this.state.selectedAreas = newAreas;
// Success
return SelectAreaError.toResponse();
}
catch (err) {
if (isDeepStrictEqual(new Set(this.state.selectedAreas), new Set(newAreas))) {
// Matter requires Success status if the areas are unchanged
logError(log, 'Service Area SelectAreas (error ignored)', err);
return SelectAreaError.toResponse();
}
else {
logError(log, 'Service Area SelectAreas', err);
return SelectAreaError.toResponse(err);
}
}
}
}
// Extend a Matter.js schema enum with new values
function extendEnum(schema, name, values) {
const element = schema.datatypes.find(e => e.name === name);
assertIsDefined(element);
for (const value of values) {
// Re-use any existing definition of the same value
if (element.children.some(e => e.id === value.id))
continue;
// Ensure new values have unique names
let name = value.name;
let suffix = 0;
while (element.children.some(e => e.name === name))
name += `_${++suffix}`;
element.children = [...element.children, { ...value, name }];
}
}
//# sourceMappingURL=endpoint-360-behavior.js.map