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.
327 lines • 17.6 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025-2026 Alexander Thoukydides
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
var useValue = arguments.length > 2;
for (var i = 0; i < initializers.length; i++) {
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
}
return useValue ? value : void 0;
};
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
var _, done = false;
for (var i = decorators.length - 1; i >= 0; i--) {
var context = {};
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
if (kind === "accessor") {
if (result === void 0) continue;
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
if (_ = accept(result.get)) descriptor.get = _;
if (_ = accept(result.set)) descriptor.set = _;
if (_ = accept(result.init)) initializers.unshift(_);
}
else if (_ = accept(result)) {
if (kind === "field") initializers.unshift(_);
else descriptor[key] = _;
}
}
if (target) Object.defineProperty(target, contextIn.name, descriptor);
done = true;
};
import { DysonDevice } from './dyson-device-base.js';
import { DysonMqtt360 } from './dyson-mqtt-360.js';
import { assertIsDefined, formatMilliseconds, formatSeconds, MS, plural, tryListener } from './utils.js';
import { ifValueChanged } from './decorator-changed.js';
import { Endpoint360 } from './endpoint-360.js';
import { PLUGIN_URL, VENDOR_ID, VENDOR_NAME } from './settings.js';
import { RvcRunMode360 } from './endpoint-360-behavior.js';
import { PowerSource, RvcOperationalState } from 'matterbridge/matter/clusters';
import { Dyson360State } from './dyson-360-types.js';
import { mapDyson360Faults } from './dyson-device-360-faults.js';
import { assert } from 'console';
import { Device360CommandHandlers } from './dyson-device-360-commands.js';
import { VendorId } from 'matterbridge/matter';
import { setTimeout } from 'node:timers/promises';
// Retry configuration for retrieving details of a completed clean
const CLEAN_RETRY_AFTER = 5 * MS; // 5 second minimum backoff
const CLEAN_RETRY_LIMIT = 5 * 60 * MS; // Give up after 1 minute
const CLEAN_RETRY_FACTOR = 2; // Double backoff on each failure
const STATE_MAP = {
// RunMode OperationalState isDocked
[Dyson360State.MachineOff]: ['Idle', 'Error', false],
[Dyson360State.FaultCallHelpline]: ['Idle', 'Error', false],
[Dyson360State.FaultContactHelpline]: ['Idle', 'Error', false],
[Dyson360State.FaultCritical]: ['Idle', 'Error', false],
[Dyson360State.FaultGettingInfo]: ['Idle', 'Error', false],
[Dyson360State.FaultLost]: ['Idle', 'Error', false],
[Dyson360State.FaultOnDock]: ['Idle', 'Error', true],
[Dyson360State.FaultOnDockCharged]: ['Idle', 'Error', true],
[Dyson360State.FaultOnDockCharging]: ['Idle', 'Error', true],
[Dyson360State.FaultReplaceOnDock]: ['Idle', 'Error', false],
[Dyson360State.FaultReturnToDock]: ['Idle', 'Error', false],
[Dyson360State.FaultRunningDiagnostic]: ['Idle', 'Error', false],
[Dyson360State.FaultUserRecoverable]: ['Idle', 'Error', false],
[Dyson360State.FullCleanAbandoned]: ['Idle', 'SeekingCharger', false],
[Dyson360State.FullCleanAborted]: ['Idle', 'SeekingCharger', false],
[Dyson360State.FullCleanCharging]: ['Cleaning', 'Charging', true],
[Dyson360State.FullCleanDiscovering]: ['Cleaning', 'Running', false],
[Dyson360State.FullCleanFinished]: ['Idle', 'SeekingCharger', false],
[Dyson360State.FullCleanInitiated]: ['Cleaning', 'Running', false],
[Dyson360State.FullCleanNeedsCharge]: ['Cleaning', 'SeekingCharger', false],
[Dyson360State.FullCleanPaused]: ['Cleaning', 'Paused', false],
[Dyson360State.FullCleanRunning]: ['Cleaning', 'Running', false],
[Dyson360State.FullCleanTraversing]: ['Cleaning', 'Running', false],
[Dyson360State.InactiveCharged]: ['Idle', 'Docked', true],
[Dyson360State.InactiveCharging]: ['Idle', 'Charging', true],
[Dyson360State.InactiveDischarging]: ['Idle', 'Stopped', false],
[Dyson360State.MappingAborted]: ['Idle', 'SeekingCharger', false],
[Dyson360State.MappingCharging]: ['Mapping', 'Charging', true],
[Dyson360State.MappingFinished]: ['Idle', 'SeekingCharger', false],
[Dyson360State.MappingInitiated]: ['Mapping', 'Running', false],
[Dyson360State.MappingNeedsCharge]: ['Mapping', 'SeekingCharger', false],
[Dyson360State.MappingPaused]: ['Mapping', 'Paused', false],
[Dyson360State.MappingRunning]: ['Mapping', 'Running', false]
};
function mapState(state) {
const [runMode, operationState, isDocked] = STATE_MAP[state];
return {
runMode: RvcRunMode360[runMode],
operationalState: RvcOperationalState.OperationalState[operationState],
isDocked
};
}
// Thresholds for battery levels
const BATTERY_THRESHOLD_CRITICAL = 10;
const BATTERY_THRESHOLD_WARNING = 25;
const BATTERY_THRESHOLD_FULL = 100;
// A Dyson robot vacuum device
let DysonDevice360Base = (() => {
let _classSuper = DysonDevice;
let _instanceExtraInitializers = [];
let _updateClusterAttributes_decorators;
return class DysonDevice360Base extends _classSuper {
static {
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
_updateClusterAttributes_decorators = [ifValueChanged];
__esDecorate(this, null, _updateClusterAttributes_decorators, { kind: "method", name: "updateClusterAttributes", static: false, private: false, access: { has: obj => "updateClusterAttributes" in obj, get: obj => obj.updateClusterAttributes }, metadata: _metadata }, null, _instanceExtraInitializers);
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
}
// The MQTT client and status update listener
static mqttConstructor = DysonMqtt360;
mqttStatusListener = __runInitializers(this, _instanceExtraInitializers);
// The RVC device endpoint
endpoint;
// State used to detect the end of a clean
runMode = RvcRunMode360.Idle;
// Construct a new Dyson device instance
constructor(...args) {
super(...args);
// Prepare listeners for MQTT updates
this.mqttStatusListener = tryListener(this.mqtt, () => this.updateClusterAttributes(this.mqtt.status));
}
// Create the endpoint for this device
makeEndpoint() {
const rvcCleanModeLabels = this.getPowerLevelMaps().map(([, mode, label]) => [mode, label]);
// Static configuration of the RVC clusters
const endpointOptions = {
id: this.uniqueId,
matterbridgeDeviceName: this.deviceName,
basicInformation: {
nodeLabel: this.deviceName,
partNumber: this.modelNumber,
productAppearance: this.getProductAppearance(),
productId: this.productId,
productLabel: this.modelNumber,
productName: this.modelName,
productUrl: PLUGIN_URL,
serialNumber: this.serialNumber,
softwareVersion: this.firmwareVersion,
uniqueId: this.uniqueId,
vendorId: VendorId(VENDOR_ID),
vendorName: VENDOR_NAME
},
powerSource: {
batteryPartNumber: this.getBatteryPartNumber()
},
rvcCleanMode: {
labels: rvcCleanModeLabels,
simpleModeTags: this.config.simpleModeTagsRvc
},
supportsMaps: this.supportsMaps()
};
// Create the endpoint and attach a command handler
const endpoint = new Endpoint360(this.log, this.config, endpointOptions);
this.attachCommandHandlers(endpoint);
return endpoint;
}
// Attach command handlers to the endpoint
attachCommandHandlers(endpoint) {
const handlers = new Device360CommandHandlers(this.log, this.mqtt, endpoint);
handlers.attachCleanModeHandler(this.makePowerCommand.bind(this));
return handlers;
}
// Indicates whether the device supports Service Area map features
supportsMaps = () => false;
// List of endpoint function names and descriptions to validate
getEntities() {
return []; // Single endpoint, so no entity selection
}
// Retrieve the root device endpoints after validation
getEndpoints(_validatedNames) {
return [this.endpoint ??= this.makeEndpoint()];
}
// Start the device after the endpoints are active
async start() {
this.mqtt.on('status', this.mqttStatusListener);
await this.updateClusterAttributes(this.mqtt.status);
}
// Stop the device when Matterbridge is shutting down
async stop() {
this.mqtt.off('status', this.mqttStatusListener);
await super.stop();
}
// Retrieve details of a completed clean
getCompletedClean(_cleanId) { return {}; }
// Retrieve details of a completed clean
async getCompletedCleanWithRetries(cleanId) {
const giveUpAt = Date.now() + CLEAN_RETRY_LIMIT;
let backoff = CLEAN_RETRY_AFTER;
for (;;) {
const result = await this.getCompletedClean(cleanId);
switch (result) {
case 'Not found':
case 'Not ready':
// Failure might be due to requesting the results too soon
if (giveUpAt < Date.now() + backoff) {
this.log.warn(`Abandoned retrieval of clean ${cleanId}: ${result}`);
return {};
}
else {
this.log.debug(`Failed to retrieve clean ${cleanId}: ${result}; retrying in ${formatMilliseconds(backoff)}...`);
await setTimeout(backoff);
backoff *= CLEAN_RETRY_FACTOR;
}
break;
case 'Unavailable':
// Not using MyDyson API or map rendering disabled
return {};
default:
// Success
return result;
}
}
}
// Log details of a completed clean
async logCompletedClean(cleanId, cleanDuration) {
// Attempt to retrieve the details of the clean
const result = await this.getCompletedCleanWithRetries(cleanId);
// Log the summary
const { charges, cleanedArea, mapLines } = result;
if (result.cleanDuration)
cleanDuration = result.cleanDuration;
const parts = [];
if (cleanedArea)
parts.push(`${cleanedArea.toFixed(2)} m²`);
if (charges !== undefined)
parts.push(`with ${plural(charges, 'charge')}`);
if (cleanDuration)
parts.push(`in ${formatSeconds(cleanDuration)}`);
if (parts.length)
this.log.info(`Cleaned ${parts.join(' ')}`);
for (const line of mapLines ?? [])
this.log.info(line);
}
// Construct a command to set power level based on an RVC Clean Mode
makePowerCommand(cleanMode) {
const map = this.getPowerLevelMaps().find(([, m]) => m === cleanMode);
assertIsDefined(map);
const powerLevel = map[0];
return {
description: map[0],
command: () => this.setPowerLevel(powerLevel),
condition: () => this.getPowerLevel() === powerLevel
};
}
// Map a Dyson power mode to its corresponding RVC Clean Mode
powerModeToCleanMode(powerMode) {
const map = this.getPowerLevelMaps().find(([m]) => m === powerMode);
assertIsDefined(map);
return map[1];
}
// Update cluster attributes when the MQTT status is updated
async updateClusterAttributes(status) {
assertIsDefined(this.endpoint);
// Map the state to cluster attribute values
const faults = mapDyson360Faults(this.log, status.state, status.faults);
const cleanMode = this.powerModeToCleanMode(this.getPowerLevel());
const { runMode } = mapState(status.state);
const operationalState = this.mapOperationalState(status, faults);
const batteryStatus = this.mapBatteryStatus(status, faults);
// Update all of the clusters
await Promise.all([
this.endpoint.updateReachable(status.reachable),
this.endpoint.updateRvcCleanMode(cleanMode),
this.endpoint.updateRvcRunMode(runMode),
this.endpoint.updateRvcOperationalState(operationalState),
this.endpoint.updatePowerSource(batteryStatus)
]);
// Check for the end of a clean
const prevRunMode = this.runMode;
this.runMode = runMode;
if (runMode === RvcRunMode360.Idle && prevRunMode === RvcRunMode360.Cleaning) {
if (status.cleanId)
await this.logCompletedClean(status.cleanId, status.cleanDuration);
}
}
// Convert the battery status to Power Source cluster attributes
mapBatteryStatus(status, faults) {
const { operationalState, isDocked } = mapState(status.state);
const { batteryChargeLevel } = status;
const { activeBatFaults, activeBatChargeFaults } = faults;
if (!isDocked)
activeBatChargeFaults.length = 0;
return batteryChargeLevel === undefined ? {
activeBatFaults,
activeBatChargeFaults,
batPercentRemaining: null,
batChargeLevel: PowerSource.BatChargeLevel.Ok,
batChargeState: PowerSource.BatChargeState[(operationalState === RvcOperationalState.OperationalState.Charging) ? 'IsCharging' : 'IsNotCharging'],
status: PowerSource.PowerSourceStatus.Unspecified
} : {
activeBatFaults,
activeBatChargeFaults,
batPercentRemaining: batteryChargeLevel * 2, // ×2, e.g. 200 for 100%
batChargeLevel: PowerSource.BatChargeLevel[batteryChargeLevel < BATTERY_THRESHOLD_CRITICAL ? 'Critical'
: batteryChargeLevel < BATTERY_THRESHOLD_WARNING ? 'Warning' : 'Ok'],
batChargeState: PowerSource.BatChargeState[(operationalState === RvcOperationalState.OperationalState.Charging) ? 'IsCharging'
: batteryChargeLevel < BATTERY_THRESHOLD_FULL ? 'IsNotCharging' : 'IsAtFullCharge'],
status: PowerSource.PowerSourceStatus.Active
};
}
// Convert the status to RVC Operational State cluster attributes
mapOperationalState(status, faults) {
const mappedState = mapState(status.state);
const isActive = mappedState.runMode !== RvcRunMode360.Idle;
// Ensure consistent Operational State and Operational Error
const { operationalError } = faults;
let { operationalState } = mapState(status.state);
if (operationalError.errorStateId !== RvcOperationalState.ErrorState.NoError) {
// Force Error state if an error is being reported
operationalState = RvcOperationalState.OperationalState.Error;
}
else {
// Otherwise the state should not have been mapped to Error
assert(operationalState !== RvcOperationalState.OperationalState.Error);
}
return { isActive, operationalState, operationalError };
}
};
})();
export { DysonDevice360Base };
//# sourceMappingURL=dyson-device-360-base.js.map