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.

452 lines 23 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 { DysonMqttAir } from './dyson-mqtt-air.js'; import { FanControl, ResourceMonitoring } from 'matterbridge/matter/clusters'; import { assertIsDefined, tryListener } from './utils.js'; import { EndpointsAir } from './endpoint-air.js'; import { PLUGIN_URL, VENDOR_ID, VENDOR_NAME } from './settings.js'; import { mapDysonAirSensorStatus } from './dyson-device-air-quality.js'; import { DysonAirAnemometerControlProfile, DysonAirAnemometerControlTilt, DysonAirAutoMode, DysonAirFanAutoPower, DysonAirFanDirection, DysonAirFanPower, DysonAirFanSpeed, DysonAirFanState, DysonAirNightMode, DysonAirOscillation, DysonAirTiltAngle, DysonAirTiltOscillation } from './dyson-air-types.js'; import { ifValueChanged } from './decorator-changed.js'; import { VendorId } from 'matterbridge/matter'; import { DysonAirSerialise } from './dyson-device-air-serialise.js'; // Mappings between FanMode and SpeedSetting const FAN_MODE_TO_SPEED_LOW = 1; const FAN_MODE_TO_SPEED_MEDIUM = 5; const FAN_MODE_TO_SPEED_HIGH = 10; const SPEED_TO_FAN_MODE_LOW = 3; // 1~3: Low const SPEED_TO_FAN_MODE_MEDIUM = 6; // 4~6: Medium, 7~9: High // Lifetime of a Pure (Hot+)Cool Link HEPA filter in operational hours const PURE_LINK_FILTER_HOURS = 4300; // Filter lifetime thresholds const FILTER_CRITICAL = 0; // % const FILTER_WARNING = 10; // % // A Dyson air treatment device let DysonDeviceAirBase = (() => { let _classSuper = DysonDevice; let _instanceExtraInitializers = []; let _updateClusterAttributes_decorators; return class DysonDeviceAirBase 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 = DysonMqttAir; mqttListener = __runInitializers(this, _instanceExtraInitializers); serialise; // The air purifier device endpoints endpoints; // Supported features hasBreeze; hasHepaFilter; hasCarbonFilter; hasDirection; hasLeftRight; hasUpDown; // Construct a new Dyson device instance constructor(...args) { super(...args); // Identify supported features from the presence of MQTT values const { status } = this.mqtt; this.hasBreeze = status.ancp !== undefined; this.hasHepaFilter = status.hflr !== undefined || status.filf !== undefined; this.hasCarbonFilter = status.cflr !== undefined; this.hasDirection = status.fdir !== undefined; this.hasLeftRight = status.oson !== undefined; this.hasUpDown = status.oton !== undefined; // Prepare a listener for MQTT updates this.mqttListener = tryListener(this.mqtt, () => this.updateClusterAttributes(this.mqtt.status)); // Prepare a set state command serialiser const flushChanged = () => { this.changed.flush(); }; this.serialise = new DysonAirSerialise(this.log, this.mqtt, flushChanged); } // Create the endpoint for this device makeEndpoints(validatedNames) { // Static configuration of the air purifier clusters const endpointOptions = { id: this.uniqueId, matterbridgeDeviceName: this.deviceName, validatedNames, basicInformation: { nodeLabel: this.deviceName, partNumber: this.modelNumber, productId: this.productId, productLabel: this.modelNumber, productName: this.modelName, productUrl: PLUGIN_URL, serialNumber: this.serialNumber, softwareVersion: this.firmwareVersion ?? this.mqtt.status.version, uniqueId: this.uniqueId, vendorId: VendorId(VENDOR_ID), vendorName: VENDOR_NAME }, fanControl: { rockSupport: { rockLeftRight: this.hasLeftRight, rockUpDown: this.hasUpDown, rockRound: false }, windSupport: { sleepWind: true, // Night mode naturalWind: this.hasBreeze }, directionSupport: this.hasDirection }, hepaFilter: this.hasHepaFilter ? { filterPartNumbers: this.classStatic.filters.hepa } : undefined, carbonFilter: this.hasCarbonFilter ? { filterPartNumbers: this.classStatic.filters.carbon } : undefined, sensors: this.sensorSupport }; // Create the endpoint return new EndpointsAir(this.log, this.config, endpointOptions); } // Install handlers async installHandlers(endpoints) { await endpoints.setFanControlHandlers({ onOff: onOff => this.setPower(onOff), airflowDirection: airflowDirection => this.setDirection(airflowDirection === FanControl.AirflowDirection.Forward), fanMode: fanMode => { switch (fanMode) { case FanControl.FanMode.Auto: return this.setFanAuto(); case FanControl.FanMode.Off: return this.setFanSpeed(0); case FanControl.FanMode.Low: return this.setFanSpeed(FAN_MODE_TO_SPEED_LOW); case FanControl.FanMode.Medium: return this.setFanSpeed(FAN_MODE_TO_SPEED_MEDIUM); default: return this.setFanSpeed(FAN_MODE_TO_SPEED_HIGH); } }, percentSetting: async (percentSetting) => { assertIsDefined(percentSetting); await this.setFanSpeed(Math.ceil(percentSetting / 10)); }, rockSetting: async (rockSetting) => { if (this.hasLeftRight) await this.setOscillateLeftRight(!!rockSetting.rockLeftRight); if (this.hasUpDown) await this.setOscillateUpDown(!!rockSetting.rockUpDown); }, speedSetting: async (speedSetting) => { assertIsDefined(speedSetting); await this.setFanSpeed(speedSetting); }, windSetting: async (windSetting) => { await this.setNightMode(!!windSetting.sleepWind); if (this.hasBreeze) await this.setOscillateBreeze(!!windSetting.naturalWind); } }); } // Determine which optional sensors are supported get sensorSupport() { const sensors = mapDysonAirSensorStatus(this.log, this.mqtt.status); return { temperature: sensors.temperature !== undefined, humidity: sensors.humidity !== undefined, voc: sensors.voc !== undefined, co2: sensors.co2 !== undefined, nox: sensors.nox !== undefined, hcho: sensors.hcho !== undefined, pm25: sensors.pm25 !== undefined, pm10: sensors.pm10 !== undefined }; } // List of endpoint function names and descriptions to validate getEntities() { const sensors = mapDysonAirSensorStatus(this.log, this.mqtt.status); const entities = [{ name: 'Air Purifier', description: 'Fan speed/oscillation control and filter monitoring' }, { name: 'Air Quality Sensor', description: 'Environmental sensor measurements' }, { name: 'Composed Air Purifier', description: 'Air purifier with integrated thermostat and sensors' }]; if (sensors.temperature !== undefined) entities.push({ name: 'Temperature Sensor', description: 'Temperature measurement' }); if (sensors.humidity !== undefined) entities.push({ name: 'Humidity Sensor', description: 'Relative humidity measurement' }); return entities; } // Retrieve the root device endpoints after validation getEndpoints(validatedNames) { this.endpoints ??= this.makeEndpoints(validatedNames); return this.endpoints.bridgedNodeEndpoints; } // Start the device after the endpoints are active async start() { assertIsDefined(this.endpoints); this.mqtt.on('status', this.mqttListener); await this.installHandlers(this.endpoints); 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(); } // Switch the fan on or off (without changing auto mode, if possible) async setPower(powerOn) { const { fpwr } = this.mqtt.status; if (powerOn) { await this.setState('Switching on', {}); // (sets an appropriate on state) } else { await this.setState('Switching off', fpwr ? { fpwr: DysonAirFanPower.Off } // Non-Link : { fmod: DysonAirFanAutoPower.Off }); // Link models } } // Switch the fan on in auto mode async setFanAuto() { const { auto } = this.mqtt.status; await this.setState('Enabling auto mode', auto ? { auto: DysonAirAutoMode.Auto } // Non-Link : { fmod: DysonAirFanAutoPower.Auto }); // Link models } // Set the airflow direction (all except Link models) async setDirection(forward) { const direction = forward ? 'Forward' : 'Backward'; await this.setState(`${direction} airflow`, { fdir: DysonAirFanDirection[direction] }); } // Set the fan speed async setFanSpeed(speed) { // Quantize and range check the speed const fnsp = Math.min(Math.round(speed), 10); if (fnsp < 1) { this.log.info('Fan speed set to 0; turning power off'); return this.setPower(false); } // Set the speed, ensuring that auto mode is disabled const { auto } = this.mqtt.status; await this.setState(`Setting fan speed to ${fnsp}`, auto ? { fnsp, auto: DysonAirAutoMode.Manual } // Non-Link : { fnsp, fmod: DysonAirFanAutoPower.Manual }); // Link models } // Set night mode async setNightMode(night) { const nightMode = night ? 'Night' : 'Day'; await this.setState(`${nightMode} mode`, { nmod: DysonAirNightMode[nightMode] }); } // Set horizontal oscillation (all except Big+Quiet models) async setOscillateLeftRight(oscillate) { // Most models use 'ON'/'OFF', but some use 'OION'/'OIOF' instead const { oson } = this.mqtt.status; const isOI = oson === DysonAirOscillation.FixedOI || oson === DysonAirOscillation.OscillatingOI; const OSON_KEYS = [['Fixed', 'FixedOI'], ['Oscillating', 'OscillatingOI']]; const key = OSON_KEYS[oscillate ? 1 : 0][isOI ? 1 : 0]; await this.setState(`${oscillate ? 'Enabling' : 'disabling'} left/right oscillation`, { oson: DysonAirOscillation[key] }); } // Set vertical oscillation (Big+Quiet models only) async setOscillateUpDown(oscillate) { const status = {}; if (oscillate) { // Enable oscillation in breeze mode status.oton = DysonAirTiltOscillation.Oscillating; status.anct = DysonAirAnemometerControlTilt.Breeze; status.otal = DysonAirTiltAngle.Breeze; status.otau = DysonAirTiltAngle.Breeze; } else { // Disable oscillation status.oton = DysonAirTiltOscillation.Fixed; status.anct = DysonAirAnemometerControlTilt.Custom; const { otal, otau } = this.mqtt.status; if (otal === DysonAirTiltAngle.Breeze || otau === DysonAirTiltAngle.Breeze) { // Set an arbitrary tilt angle if breeze mode was enabled status.otal = DysonAirTiltAngle.Degrees0; status.otau = DysonAirTiltAngle.Degrees0; } } await this.setState(`${oscillate ? 'Enabling' : 'disabling'} up/down oscillation`, status); } // Set breeze oscillation and fan speed (Humidify models only) async setOscillateBreeze(breeze) { const status = { oson: DysonAirOscillation.Oscillating }; if (breeze) { // Enable oscillation in breeze mode status.ancp = DysonAirAnemometerControlProfile.Breeze; } else { // Enable non-breeze oscillation const { ancp } = this.mqtt.status; if (ancp === DysonAirAnemometerControlProfile.Breeze) { // Set an arbitrary oscillation angle if breeze mode was enabled status.ancp = DysonAirAnemometerControlProfile.Degrees180; } } await this.setState(`${breeze ? 'Enabling' : 'disabling'} breeze oscillation`, status); } // Send an MQTT command to set the product state async setState(description, productState) { // Also switch to an active power state, unless an alternative specified const { fpwr, fmod } = this.mqtt.status; if (fpwr === DysonAirFanPower.Off) productState.fpwr ??= DysonAirFanPower.On; // Non-Link if (fmod === DysonAirFanAutoPower.Off) productState.fmod ??= DysonAirFanAutoPower.Manual; // Link models // Queue the MQTT command await this.serialise.setState(description, productState); } // Update cluster attributes when the MQTT status is updated async updateClusterAttributes(status) { if (this.serialise.ignoreMqttUpdates) { this.log.info('Ignoring status update whilst command pending'); } else { // Update the cluster attributes const fanStatus = this.mapDysonFanControlStatus(status); const filterStatus = this.mapDysonFilterStatus(status); const sensorStatus = mapDysonAirSensorStatus(this.log, status); await Promise.all([ this.endpoints?.updateReachable(status.reachable), this.endpoints?.updateFanControl(fanStatus), this.endpoints?.updateFilterMonitoring(filterStatus), this.endpoints?.updateSensors(sensorStatus) ]); } } // Convert the status to On/Off and Fan Control cluster attributes mapDysonFanControlStatus(status) { const { ancp, auto, fdir, fpwr, fmod, fnsp, fnst, nmod, oson, oton } = status; // Start by determining the actual device state const onOff = fpwr ? fpwr !== DysonAirFanPower.Off // Non-Link : fmod !== DysonAirFanAutoPower.Off; // Link models const isAuto = auto === DysonAirAutoMode.Auto // Non-Link || fnsp === DysonAirFanSpeed.Auto; // Link const isSpinning = fnst === DysonAirFanState.Running; // Link models do not preserve speed setting in auto, so default to max let speedSetting = typeof fnsp === 'number' ? fnsp : 10; // Start by mapping the speed to a mode let fanMode = FanControl.FanMode[speedSetting <= SPEED_TO_FAN_MODE_LOW ? 'Low' : speedSetting <= SPEED_TO_FAN_MODE_MEDIUM ? 'Medium' : 'High']; let speedCurrent; if (!onOff) { // Off, fan stopped speedCurrent = 0; fanMode = FanControl.FanMode.Off; speedSetting = 0; } else { if (isAuto) { // Auto mode: assume fan either at maximum speed or stopped fanMode = FanControl.FanMode.Auto; speedSetting = null; speedCurrent = isSpinning ? 10 : 0; } else { // Manual mode: the fan is at the requested speed speedCurrent = speedSetting; } } // Night mode const sleepWind = nmod === DysonAirNightMode.Night; // Airflow direction let airflowDirection; if (this.hasDirection && fdir) { // Non-Link models only airflowDirection = FanControl.AirflowDirection[fdir === DysonAirFanDirection.Forward ? 'Forward' : 'Reverse']; } // Various oscillation modes let rockLeftRight = false, rockUpDown = false, naturalWind = false; if (this.hasLeftRight) { // All except Big+Quiet models rockLeftRight = oson === DysonAirOscillation.Oscillating || oson === DysonAirOscillation.OscillatingOI; } if (this.hasUpDown) { // Big+Quiet models only rockUpDown = oton === DysonAirTiltOscillation.Oscillating; } if (this.hasBreeze) { // Humidify+Cool models only naturalWind = ancp === DysonAirAnemometerControlProfile.Breeze; } // Return the mapped values let rockSetting; if (this.hasLeftRight || this.hasUpDown) rockSetting = { rockLeftRight, rockUpDown }; return { airflowDirection, fanMode, onOff, percentCurrent: speedCurrent * 10, percentSetting: speedSetting === null ? null : speedSetting * 10, rockSetting, speedCurrent, speedSetting, windSetting: { sleepWind, naturalWind } }; } // Convert the status to Filter Monitoring cluster attributes mapDysonFilterStatus(status) { const filterStatus = (percent) => { const inPlaceIndicator = typeof percent === 'number'; const condition = inPlaceIndicator ? percent : 0; const changeIndication = ResourceMonitoring.ChangeIndication[condition <= FILTER_CRITICAL ? 'Critical' : condition <= FILTER_WARNING ? 'Warning' : 'Ok']; return { condition, changeIndication, inPlaceIndicator }; }; // HEPA filter remaining life let hepaPercent = status.hflr; if (status.filf !== undefined) { // For Pure (Hot+)Cool Link convert remaining hours to percentage hepaPercent = Math.round(status.filf * 100 / PURE_LINK_FILTER_HOURS); } // Convert the filter status to Matter attribute representation return { hepa: this.hasHepaFilter ? filterStatus(hepaPercent) : undefined, carbon: this.hasCarbonFilter ? filterStatus(status.cflr) : undefined }; } }; })(); export { DysonDeviceAirBase }; //# sourceMappingURL=dyson-device-air-base.js.map