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
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 { DysonMqttAir } from './dyson-mqtt-air.js';
import { FanControl, ResourceMonitoring } from 'matterbridge/matter/clusters';
import { assertIsDefined, formatList, 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 { CC, RI } from './logger-options.js';
import { ifValueChanged } from './decorator-changed.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);
// The air purifier device endpoints
endpoints;
// Supported features
hasBreeze;
hasHepaFilter;
hasCarbonFilter;
hasDirection;
hasLeftRight;
hasUpDown;
// Should FanMode be used (in addition to On/Off) for fan off control
useFanModeOff = false;
// 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));
}
// Create the endpoint for this device
makeEndpoints(validatedNames) {
// Static configuration of the air purifier clusters
const endpointOptions = {
uniqueStorageKey: 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.mqtt.status.version,
uniqueId: this.uniqueId,
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: percentSetting => 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: speedSetting => 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 {
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() {
return [{
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'
}, {
name: 'Humidity Sensor',
description: 'Relative humidity measurement'
}, {
name: 'Temperature Sensor',
description: 'Temperature measurement'
}];
}
// 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, fmod } = this.mqtt.status;
const isOn = fpwr ? fpwr !== DysonAirFanPower.Off // Non-Link
: fmod !== DysonAirFanAutoPower.Off; // Link models
if (isOn === powerOn) {
this.log.info(`Fan is already ${powerOn ? 'on' : 'off'}; no action required`);
}
else if (powerOn) {
this.log.info('Switching on');
await this.setState({}); // (sets an appropriate on state)
}
else {
this.log.info('Switching off');
await this.setState(fpwr ? { fpwr: DysonAirFanPower.Off } // Non-Link
: { fmod: DysonAirFanAutoPower.Off }); // Link models
}
}
// Switch the fan on in auto mode
async setFanAuto() {
this.log.info('Enabling auto mode');
const { auto } = this.mqtt.status;
await this.setState(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';
this.log.info(`${direction} airflow`);
await this.setState({ 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');
this.useFanModeOff = true;
return this.setPower(false);
}
// Set the speed, ensuring that auto mode is disabled
this.log.info(`Setting fan speed to ${fnsp}`);
const { auto } = this.mqtt.status;
await this.setState(auto ? { fnsp, auto: DysonAirAutoMode.Manual } // Non-Link
: { fnsp, fmod: DysonAirFanAutoPower.Manual }); // Link models
}
// Set night mode
async setNightMode(night) {
const nightMode = night ? 'Night' : 'Day';
this.log.info(`${nightMode} mode`);
await this.setState({ nmod: DysonAirNightMode[nightMode] });
}
// Set horizontal oscillation (all except Big+Quiet models)
async setOscillateLeftRight(oscillate) {
this.log.info(`${oscillate ? 'Enabling' : 'disabling'} left/right oscillation`);
// 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({ oson: DysonAirOscillation[key] });
}
// Set vertical oscillation (Big+Quiet models only)
async setOscillateUpDown(oscillate) {
this.log.info(`${oscillate ? 'Enabling' : 'disabling'} up/down oscillation`);
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(status);
}
// Set breeze oscillation and fan speed (Humidify models only)
async setOscillateBreeze(breeze) {
this.log.info(`${breeze ? 'Enabling' : 'disabling'} breeze oscillation`);
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(status);
}
// Send an MQTT command to set the product state
async setState(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
// Publish the command
const values = Object.entries(productState).map(([key, value]) => `${CC}${key}=${value}${RI}`);
this.log.info(`Setting state: ${formatList(values)}`);
await this.mqtt.commandStateSet(productState);
// Ensure that the next MQTT status update is processed
this.changed.flush();
}
// Update cluster attributes when the MQTT status is updated
async updateClusterAttributes(status) {
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;
if (this.useFanModeOff) {
// Fan Control was set to Off or 0 speed via Matter
fanMode = FanControl.FanMode.Off;
speedSetting = 0;
}
}
else {
this.useFanModeOff = false;
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