UNPKG

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