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.
234 lines • 13 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025 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, tryListener } from './utils.js';
import { ifValueChanged } from './decorator-changed.js';
import { Endpoint360 } from './endpoint-360.js';
import { PLUGIN_URL, 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 { attachDevice360CommandHandlers } from './dyson-device-360-commands.js';
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;
mqttListener = __runInitializers(this, _instanceExtraInitializers);
// The RVC device endpoint
endpoint;
// Construct a new Dyson device instance
constructor(...args) {
super(...args);
// Prepare a listener for MQTT updates
this.mqttListener = tryListener(this.mqtt, () => this.updateClusterAttributes(this.mqtt.status));
}
// Create the endpoint for this device
makeEndpoint() {
const rvcCleanModeLabels = this.getPowerModeMaps().map(([, mode, label]) => [mode, label]);
// Static configuration of the RVC clusters
const endpointOptions = {
uniqueStorageKey: this.uniqueId,
matterbridgeDeviceName: this.deviceName,
deviceBasicInformation: {
nodeLabel: this.deviceName,
partNumber: this.modelNumber,
productAppearance: this.getProductAppearance(),
productLabel: this.modelNumber,
productName: this.modelName,
productUrl: PLUGIN_URL,
serialNumber: this.serialNumber,
uniqueId: this.uniqueId,
vendorName: VENDOR_NAME
},
powerSource: {
batteryPartNumber: this.getBatteryPartNumber()
},
rvcCleanMode: {
labels: rvcCleanModeLabels
}
};
// Create the endpoint and attach a command handler
const endpoint = new Endpoint360(this.log, this.config, endpointOptions);
attachDevice360CommandHandlers(this.log, this.mqtt, endpoint, this.cleanModeToPowerMode.bind(this));
return endpoint;
}
// 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.mqttListener);
await this.updateClusterAttributes(this.mqtt.status);
}
// Stop the device when Matterbridge is shutting down
async stop() {
this.mqtt.off('status', this.mqttListener);
await super.stop();
}
// Map an RVC Clean Mode to its corresponding Dyson power mode
cleanModeToPowerMode(cleanMode) {
const map = this.getPowerModeMaps().find(([, m]) => m === cleanMode);
assertIsDefined(map);
return map[0];
}
// Map a Dyson power mode to its corresponding RVC Clean Mode
powerModeToCleanMode(powerMode) {
const map = this.getPowerModeMaps().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(status.currentVacuumPowerMode);
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)
]);
}
// 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;
return {
activeBatFaults,
activeBatChargeFaults: isDocked ? 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