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.

327 lines 17.6 kB
// 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