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.
460 lines • 27.2 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 { airPurifier, airQualitySensor, bridgedNode, humiditySensor, temperatureSensor, thermostatDevice } from 'matterbridge';
import { EndpointBase, formatEnumLog } from './endpoint-base.js';
import { ActivatedCarbonFilterMonitoring, AirQuality, ConcentrationMeasurement, CarbonDioxideConcentrationMeasurement, FanControl, FormaldehydeConcentrationMeasurement, HepaFilterMonitoring, NitrogenDioxideConcentrationMeasurement, OnOff, Pm10ConcentrationMeasurement, Pm25ConcentrationMeasurement, RelativeHumidityMeasurement, ResourceMonitoring, TemperatureMeasurement, Thermostat, TotalVolatileOrganicCompoundsConcentrationMeasurement } from 'matterbridge/matter/clusters';
import { createActivatedCarbonFilterMonitoringClusterServer, createFanControlClusterServer, createHepaFilterMonitoringClusterServer, createOnOffClusterServer } from './endpoint-air-purifier.js';
import { Changed, ifValueChanged } from './decorator-changed.js';
import { AN, AV, CN, CV, RI } from './logger-options.js';
import { assertIsBoolean, assertIsNumber, formatList } from './utils.js';
import { createAirQualityClusterServer, createTemperatureMeasurementClusterServer, createRelativeHumidityMeasurementClusterServer, createTotalVolatileOrganicCompoundsConcentrationMeasurementClusterServer, createCarbonDioxideConcentrationMeasurementClusterServer, createNitrogenDioxideConcentrationMeasurementClusterServer, createFormaldehydeConcentrationMeasurementClusterServer, createPm25ConcentrationMeasurementClusterServer, createPm10ConcentrationMeasurementClusterServer } from './endpoint-air-quality.js';
import { createThermostatClusterServer } from './endpoint-air-thermostat.js';
import { logError } from './log-error.js';
// A Matterbridge endpoint for an air purifier composite device
let EndpointsAir = (() => {
let _instanceExtraInitializers = [];
let _updateReachable_decorators;
let _updateFanControl_decorators;
let _updateThermostat_decorators;
let _updateFilterMonitoring_decorators;
let _updateSensors_decorators;
return class EndpointsAir {
static {
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
_updateReachable_decorators = [ifValueChanged];
_updateFanControl_decorators = [ifValueChanged];
_updateThermostat_decorators = [ifValueChanged];
_updateFilterMonitoring_decorators = [ifValueChanged];
_updateSensors_decorators = [ifValueChanged];
__esDecorate(this, null, _updateReachable_decorators, { kind: "method", name: "updateReachable", static: false, private: false, access: { has: obj => "updateReachable" in obj, get: obj => obj.updateReachable }, metadata: _metadata }, null, _instanceExtraInitializers);
__esDecorate(this, null, _updateFanControl_decorators, { kind: "method", name: "updateFanControl", static: false, private: false, access: { has: obj => "updateFanControl" in obj, get: obj => obj.updateFanControl }, metadata: _metadata }, null, _instanceExtraInitializers);
__esDecorate(this, null, _updateThermostat_decorators, { kind: "method", name: "updateThermostat", static: false, private: false, access: { has: obj => "updateThermostat" in obj, get: obj => obj.updateThermostat }, metadata: _metadata }, null, _instanceExtraInitializers);
__esDecorate(this, null, _updateFilterMonitoring_decorators, { kind: "method", name: "updateFilterMonitoring", static: false, private: false, access: { has: obj => "updateFilterMonitoring" in obj, get: obj => obj.updateFilterMonitoring }, metadata: _metadata }, null, _instanceExtraInitializers);
__esDecorate(this, null, _updateSensors_decorators, { kind: "method", name: "updateSensors", static: false, private: false, access: { has: obj => "updateSensors" in obj, get: obj => obj.updateSensors }, metadata: _metadata }, null, _instanceExtraInitializers);
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
}
log = __runInitializers(this, _instanceExtraInitializers);
config;
options;
// Bridge node endpoints
bridged = [];
// Aliases to endpoints for specific clusters
purifier; // On/Off + Air Purifier + X Filter Monitoring
thermostat; // Thermostat
airQuality = []; // Air Quality + X Measurement
temperature = []; // Temperature Measurement
humidity = []; // Relative Humidity Measurement
// Decorator support
changed;
// Filter own attribute writes from subscription events
lastWrite = new Map;
// Construct a new endpoint
constructor(log, config, options) {
this.log = log;
this.config = config;
this.options = options;
// Construct the separately bridged devices, if enabled
this.createAirPurifierEndpoint();
this.createThermostatEndpoint();
this.createHumiditySensorEndpoint();
this.createTemperatureSensorEndpoint();
this.createAirQualitySensorEndpoint();
// Construct the composed air purifier device, if enabled
const parent = this.createAirPurifierEndpoint(true);
if (parent) {
this.createThermostatEndpoint(parent);
this.createHumiditySensorEndpoint(parent);
this.createTemperatureSensorEndpoint(parent);
this.createAirQualitySensorEndpoint(parent);
}
// Prepare the decorator support
this.changed = new Changed(log);
}
// Create an Air Purifier device
createAirPurifierEndpoint(composed = false) {
// Create the endpoint
if (this.purifier)
return;
const endpointName = composed ? 'Composed Air Purifier' : 'Air Purifier';
const endpoint = this.createDevice(endpointName, [airPurifier]);
if (!endpoint)
return;
this.purifier = endpoint;
// Create the device-specific clusters
const { fanControl, hepaFilter, carbonFilter } = this.options;
createOnOffClusterServer(endpoint);
createFanControlClusterServer(endpoint, fanControl);
if (hepaFilter)
createHepaFilterMonitoringClusterServer(endpoint, hepaFilter);
if (carbonFilter)
createActivatedCarbonFilterMonitoringClusterServer(endpoint, carbonFilter);
return endpoint;
}
// Create a Humidity Sensor device
createHumiditySensorEndpoint(parent) {
// Create the endpoint
const endpoint = this.createDevice('Humidity Sensor', [humiditySensor], parent);
if (!endpoint)
return;
this.humidity.push(endpoint);
// Create the device-specific clusters
createRelativeHumidityMeasurementClusterServer(endpoint);
return endpoint;
}
// Create a Temperature Sensor device
createTemperatureSensorEndpoint(parent) {
// Create the endpoint
const endpoint = this.createDevice('Temperature Sensor', [temperatureSensor], parent);
if (!endpoint)
return;
this.temperature.push(endpoint);
// Create the device-specific clusters
createTemperatureMeasurementClusterServer(endpoint);
return endpoint;
}
// Create a Thermostat device
createThermostatEndpoint(parent) {
// Create the endpoint
if (this.thermostat)
return;
const endpoint = this.createDevice('Thermostat', [thermostatDevice], parent);
if (!endpoint)
return;
this.thermostat = endpoint;
// Create the device-specific clusters
createThermostatClusterServer(endpoint);
return endpoint;
}
// Create an Air Quality Sensor device
createAirQualitySensorEndpoint(parent) {
// Create the endpoint
const endpoint = this.createDevice('Air Quality Sensor', [airQualitySensor, temperatureSensor, humiditySensor], parent);
if (!endpoint)
return;
this.airQuality.push(endpoint);
this.temperature.push(endpoint);
this.humidity.push(endpoint);
// Create the device-specific clusters
const { sensors } = this.options;
createAirQualityClusterServer(endpoint);
createTemperatureMeasurementClusterServer(endpoint);
createRelativeHumidityMeasurementClusterServer(endpoint);
if (sensors.voc)
createTotalVolatileOrganicCompoundsConcentrationMeasurementClusterServer(endpoint);
if (sensors.co2)
createCarbonDioxideConcentrationMeasurementClusterServer(endpoint);
if (sensors.nox)
createNitrogenDioxideConcentrationMeasurementClusterServer(endpoint);
if (sensors.hcho)
createFormaldehydeConcentrationMeasurementClusterServer(endpoint);
if (sensors.pm25)
createPm25ConcentrationMeasurementClusterServer(endpoint);
if (sensors.pm10)
createPm10ConcentrationMeasurementClusterServer(endpoint);
return endpoint;
}
// Create a device as either a bridged node or a child endpoint
createDevice(endpointName, definition, parent) {
const { config, options } = this;
const debug = config.debugFeatures.includes('Log Endpoint Debug');
// Construct a unique Matter.js endpoint identifier
const uniqueStorageKey = `${options.uniqueStorageKey}-${endpointName.toLowerCase()}`;
if (parent) {
// Create a child endpoint with an Identify cluster
const endpoint = parent.addChildDeviceType(endpointName, definition, { uniqueStorageKey }, debug);
endpoint.createDefaultIdentifyClusterServer();
return endpoint;
}
else {
// Only create the bridged node if allowed by the configuration
if (!this.options.validatedNames.includes(endpointName))
return;
// Construct other unique identifiers for this bridged node
const { basicInformation: deviceBasicInformation } = options;
const matterbridgeDeviceName = `${this.options.matterbridgeDeviceName} (${endpointName})`;
const suffix = `-${this.bridged.length}`;
const uniqueId = `${deviceBasicInformation.uniqueId.substring(0, 32 - suffix.length)}${suffix}`;
const nodeOptions = {
uniqueStorageKey,
matterbridgeDeviceName,
basicInformation: {
...deviceBasicInformation,
uniqueId
}
};
// Create a bridged node endpoint
// (includes Identify and Bridged Device Basic Information clusters)
const endpoint = new EndpointBase(this.log, config, nodeOptions, [bridgedNode, ...definition]);
this.bridged.push(endpoint);
return endpoint;
}
}
// All bridged device endpoints
get bridgedNodeEndpoints() {
return this.bridged;
}
// Install On/Off and Fan Control cluster handlers
async setFanControlHandlers(handlers) {
const endpoint = this.purifier;
if (!endpoint)
return;
// Subscribe to Fan Control read/write attributes
const keys = ['fanMode', 'percentSetting', 'rockSetting', 'speedSetting', 'windSetting'];
await this.subscribeAttributes(endpoint, FanControl.Cluster.id, 'Fan Control', handlers, keys);
// Install On/Off command handlers
const setOnOff = async (command, newValue) => {
this.log.debug(`On/Off command: ${command}`);
const oldValue = endpoint.getAttribute(OnOff.Cluster.id, 'onOff', this.log);
assertIsBoolean(oldValue);
newValue ??= !oldValue; // (for Toggle command)
// Call the handler and then update the attribute
await handlers.onOff(newValue, oldValue);
await endpoint.setAttribute(OnOff.Cluster.id, 'onOff', newValue, this.log);
};
endpoint.addCommandHandler('on', () => { void setOnOff('On', true); });
endpoint.addCommandHandler('off', () => { void setOnOff('Off', false); });
endpoint.addCommandHandler('toggle', () => { void setOnOff('Toggle'); });
}
// Install Thermostat cluster handlers
async setThermostatHandlers(handlers) {
const endpoint = this.thermostat;
if (!endpoint)
return;
// Subscribe to Thermostat read/write attributes
await this.subscribeAttributes(endpoint, Thermostat.Cluster.id, 'Thermostat', handlers);
// Install Thermostat command handler
endpoint.addCommandHandler('setpointRaiseLower', async ({ request }) => {
const { mode, amount } = request;
this.log.debug(`Thermostat SetpointRaiseLower command: ${Thermostat.SetpointRaiseLowerMode[mode]} ${amount}`);
if ([Thermostat.SetpointRaiseLowerMode.Heat, Thermostat.SetpointRaiseLowerMode.Both].includes(mode)) {
// Treat the command as a write to occupiedHeatingSetpoint
const oldValue = endpoint.getAttribute(Thermostat.Cluster.id, 'occupiedHeatingSetpoint', this.log);
assertIsNumber(oldValue);
const newValue = oldValue + amount * 10;
// Call the handler and then update the attribute
await handlers.occupiedHeatingSetpoint(newValue, oldValue);
await endpoint.setAttribute(Thermostat.Cluster.id, 'occupiedHeatingSetpoint', newValue, this.log);
}
});
}
// Subscribe to attribute updates
async subscribeAttributes(endpoint, clusterId, name, handlers, keys) {
keys ??= Object.keys(handlers);
await Promise.all(keys.map(async (key) => {
const description = `${name} ${key}`;
// Wrapper around handler to trap errors and flush the change cache
const handler = (newValue, oldValue, context) => {
// Ignore reflected local writes (and duplicates)
if (context.offline === true)
return;
// Call the handler and then ensure the next update gets applied
this.log.info(`${CN}${description}${RI}: ${JSON.stringify(oldValue)} → ${CV}${JSON.stringify(newValue)}${RI}`);
void (async () => {
try {
await handlers[key](newValue, oldValue);
this.updateNextStatus();
}
catch (err) {
logError(this.log, description, err);
}
})();
};
// Register the handler
const success = await endpoint.subscribeAttribute(clusterId, key, handler, this.log);
if (!success)
this.log.warn(`${description} subscription failed`);
}));
}
// Update the Bridged Device Basic Information cluster attributes
async updateReachable(reachable) {
await Promise.all(this.bridged.map(e => e.updateReachable(reachable)));
}
// Update the On/Off and Fan Control cluster attributes
async updateFanControl(fan) {
const endpoint = this.purifier;
if (!endpoint)
return;
// Matterbridge detects missing bitmap values as changes, so set defaults
if (fan.rockSetting)
fan.rockSetting = {
rockLeftRight: false, rockUpDown: false, rockRound: false,
...fan.rockSetting
};
if (fan.windSetting)
fan.windSetting = {
sleepWind: false, naturalWind: false,
...fan.windSetting
};
// Log the new values
const { onOff, airflowDirection, fanMode, percentSetting, rockSetting, speedSetting, windSetting, percentCurrent, speedCurrent } = fan;
this.log.info(`${AN}On/Off${RI}: ${onOff ? 'On' : 'Off'}`);
const logParts = [
`current speed ${AV}${speedCurrent}${RI} (${AV}${percentCurrent}${RI} %)`,
`set speed ${AV}${speedSetting}${RI} (${AV}${percentSetting}${RI} %)`,
formatEnumLog(FanControl.FanMode, fanMode)
];
if (rockSetting?.rockLeftRight)
logParts.push('rock left/right');
if (rockSetting?.rockUpDown)
logParts.push('rock up/down');
if (rockSetting?.rockRound)
logParts.push('rock round');
if (windSetting?.sleepWind)
logParts.push('sleep wind');
if (windSetting?.naturalWind)
logParts.push('natural wind');
if (airflowDirection !== undefined) {
logParts.push(formatEnumLog(FanControl.AirflowDirection, airflowDirection));
}
this.log.info(`${AN}Fan Control${RI}: ${formatList(logParts)}`);
// Perform the cluster attribute updates
await endpoint.updateAttribute(OnOff.Cluster.id, 'onOff', onOff, this.log);
const fanAttributes = ['airflowDirection', 'fanMode', 'percentSetting', 'rockSetting',
'speedSetting', 'windSetting', 'percentCurrent', 'speedCurrent'];
for (const attribute of fanAttributes) {
const value = fan[attribute];
if (value !== undefined)
await endpoint.updateAttribute(FanControl.Cluster.id, attribute, value, this.log);
}
}
// Update the Thermostat cluster attributes
async updateThermostat(thermostat) {
const endpoint = this.thermostat;
if (!endpoint)
return;
// Matterbridge detects missing bitmap values as changes, so set defaults
thermostat.thermostatRunningState = {
cool: false, heatStage2: false, coolStage2: false, fanStage2: false, fanStage3: false,
...thermostat.thermostatRunningState
};
// Log the new values
const { occupiedHeatingSetpoint, systemMode, localTemperature, piHeatingDemand, thermostatRunningState } = thermostat;
const logParts = [
formatEnumLog(Thermostat.SystemMode, systemMode),
`demand ${AV}${piHeatingDemand}${RI} %`,
`target ${AV}${(occupiedHeatingSetpoint / 100).toFixed(2)}${RI} °C`
];
if (localTemperature !== null) {
logParts.push(`currently ${AV}${(localTemperature / 100).toFixed(2)}${RI} °C`);
}
if (thermostatRunningState.heat)
logParts.push('heating');
if (thermostatRunningState.fanStage3)
logParts.push('fan stage 3');
else if (thermostatRunningState.fanStage2)
logParts.push('fan stage 2');
else if (thermostatRunningState.fan)
logParts.push('fan stage 1');
this.log.info(`${AN}Thermostat${RI}: ${formatList(logParts)}`);
// Perform the cluster attribute updates
const attributes = ['occupiedHeatingSetpoint', 'systemMode', 'localTemperature',
'piHeatingDemand', 'thermostatRunningState'];
for (const attribute of attributes) {
await endpoint.updateAttribute(Thermostat.Cluster.id, attribute, thermostat[attribute], this.log);
}
}
// Update the HEPA and Activated Carbon Filter Monitoring cluster attributes
async updateFilterMonitoring(filters) {
const updateCluster = async (clusterId, name, { condition, changeIndication, inPlaceIndicator }) => {
// Log the new values
this.log.info(`${AN}${name} Filter${RI}: ${AV}${condition}${RI}% `
+ formatEnumLog(ResourceMonitoring.ChangeIndication, changeIndication)
+ `${inPlaceIndicator ? '' : ' not'} installed`);
// Perform the cluster attribute updates
const endpoint = this.purifier;
await endpoint?.updateAttribute(clusterId, 'condition', condition, this.log);
await endpoint?.updateAttribute(clusterId, 'changeIndication', changeIndication, this.log);
if (inPlaceIndicator)
await endpoint?.updateAttribute(clusterId, 'inPlaceIndicator', inPlaceIndicator, this.log);
};
// Update the status of both filters
const { hepa, carbon } = filters;
if (hepa)
await updateCluster(HepaFilterMonitoring.Cluster.id, 'HEPA', hepa);
if (carbon)
await updateCluster(ActivatedCarbonFilterMonitoring.Cluster.id, 'Activated Carbon', carbon);
}
// Update all of the Air Quality and Measurement cluster attributes
async updateSensors(measurements) {
const { airQuality, temperature, humidity, voc, co2, nox, hcho, pm25, pm10 } = measurements;
// Log the new values
const logMeasurements = [];
const logEnum = (value, enumType, suffix) => {
if (value === undefined)
return;
logMeasurements.push(`${formatEnumLog(enumType, value)} ${suffix}`);
};
const logNumber = (value, transform, suffix) => {
if (value === undefined)
return;
const formatted = transform && value !== null ? transform(value) : value;
logMeasurements.push(`${AV}${formatted}${RI} ${suffix}`);
};
logEnum(airQuality, AirQuality.AirQualityEnum, 'air quality');
logNumber(temperature, (v) => (v / 100).toFixed(2), '°C');
logNumber(humidity, (v) => (v / 100).toFixed(2), '% RH');
logEnum(voc, ConcentrationMeasurement.LevelValue, 'VOC');
logNumber(co2, undefined, 'ppm CO2');
logEnum(nox, ConcentrationMeasurement.LevelValue, 'NOx');
logNumber(hcho, undefined, 'µg/m³ H-CHO');
logNumber(pm25, undefined, 'µg/m³ PM2.5');
logNumber(pm10, undefined, 'µg/m³ PM10');
this.log.info(`${AN}Air Quality Measurements${RI}: ${formatList(logMeasurements)}`);
// Perform the cluster attribute updates
const attributes = [
[this.airQuality, AirQuality.Cluster.id, 'airQuality', airQuality],
[this.temperature, TemperatureMeasurement.Cluster.id, 'measuredValue', temperature],
[this.humidity, RelativeHumidityMeasurement.Cluster.id, 'measuredValue', humidity],
[this.airQuality, TotalVolatileOrganicCompoundsConcentrationMeasurement.Cluster.id, 'levelValue', voc],
[this.airQuality, CarbonDioxideConcentrationMeasurement.Cluster.id, 'measuredValue', co2],
[this.airQuality, NitrogenDioxideConcentrationMeasurement.Cluster.id, 'levelValue', nox],
[this.airQuality, FormaldehydeConcentrationMeasurement.Cluster.id, 'measuredValue', hcho],
[this.airQuality, Pm25ConcentrationMeasurement.Cluster.id, 'measuredValue', pm25],
[this.airQuality, Pm10ConcentrationMeasurement.Cluster.id, 'measuredValue', pm10]
];
const updatePromises = attributes.flatMap(([endpoints, clusterId, attribute, value]) => value === undefined ? [] : endpoints.map(e => e.updateAttribute(clusterId, attribute, value, this.log)));
await Promise.all(updatePromises);
}
// Ensure that the next MQTT status update is applied to the clusters
updateNextStatus() {
this.changed.flush('updateFanControl');
this.changed.flush('updateThermostat');
}
};
})();
export { EndpointsAir };
//# sourceMappingURL=endpoint-air.js.map