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.

483 lines 28.2 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 { airPurifier, airQualitySensor, bridgedNode, humiditySensor, temperatureSensor, thermostatDevice } from 'matterbridge'; import { EndpointBase, formatEnumLog } from './endpoint-base.js'; import { AirQuality, ConcentrationMeasurement, FanControl, ResourceMonitoring, Thermostat } from 'matterbridge/matter/clusters'; import { activatedCarbonFilterMonitoringBehavior, createActivatedCarbonFilterMonitoringClusterServer, createFanControlClusterServer, createHepaFilterMonitoringClusterServer, createOnOffClusterServer, fanControlBehavior, hepaFilterMonitoringBehavior, onOffBehavior } from './endpoint-air-purifier.js'; import { Changed, ifValueChanged } from './decorator-changed.js'; import { AN, AV, CN, CV, RI } from './logger-options.js'; import { assertIsBoolean, assertIsDefined, formatList } from './utils.js'; import { createAirQualityClusterServer, createTemperatureMeasurementClusterServer, createRelativeHumidityMeasurementClusterServer, createTotalVolatileOrganicCompoundsConcentrationMeasurementClusterServer, createCarbonDioxideConcentrationMeasurementClusterServer, createNitrogenDioxideConcentrationMeasurementClusterServer, createFormaldehydeConcentrationMeasurementClusterServer, createPm25ConcentrationMeasurementClusterServer, createPm10ConcentrationMeasurementClusterServer, airQualityBehavior, temperatureMeasurementBehavior, relativeHumidityMeasurementBehavior, totalVolatileOrganicCompoundsConcentrationMeasurementBehavior, carbonDioxideConcentrationMeasurementBehavior, nitrogenDioxideConcentrationMeasurementBehavior, formaldehydeConcentrationMeasurementBehavior, pm25ConcentrationMeasurementBehavior, pm10ConcentrationMeasurementBehavior } from './endpoint-air-quality.js'; import { createThermostatClusterServer, thermostatBehavior } from './endpoint-air-thermostat.js'; import { logError } from './log-error.js'; import { BehaviorAir, BehaviorDeviceAir } from './endpoint-air-behaviour.js'; import { StatusResponse } from 'matterbridge/matter/types'; // 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 // Command handler behaviors behaviorDeviceAir; // 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); } // Add a command handler behavior this.behaviorDeviceAir = new BehaviorDeviceAir(this.log); this.thermostat?.behaviors.require(BehaviorAir, { device: this.behaviorDeviceAir }); // 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 if (!this.options.sensors.humidity) return; 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 if (!this.options.sensors.temperature) return; 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); // Create the device-specific clusters const { sensors } = this.options; createAirQualityClusterServer(endpoint); if (sensors.temperature) { createTemperatureMeasurementClusterServer(endpoint); this.temperature.push(endpoint); } if (sensors.humidity) { createRelativeHumidityMeasurementClusterServer(endpoint); this.humidity.push(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 id = `${options.id}-${endpointName.toLowerCase()}`; if (parent) { // Create a child endpoint with an Identify cluster const endpoint = parent.addChildDeviceType(endpointName, definition, { id }, 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 = { id, 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 { onOff: onOffHandler, ...otherHandlers } = handlers; await this.subscribeAttributes(endpoint, fanControlBehavior, 'Fan Control', otherHandlers); // Install On/Off command handlers const setOnOff = async (command, newValue) => { this.log.debug(`On/Off command: ${command}`); const oldValue = endpoint.getAttribute(onOffBehavior, 'onOff', this.log); assertIsBoolean(oldValue); newValue ??= !oldValue; // (for Toggle command) // Call the handler await onOffHandler(newValue, oldValue); // (status update will set the OnOff attribute) }; 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, thermostatBehavior, 'Thermostat', handlers); // Install Thermostat command handler this.behaviorDeviceAir.setCommandHandler('SetpointRaiseLower', async ({ mode, amount }) => { 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(thermostatBehavior, 'occupiedHeatingSetpoint', this.log); assertIsDefined(oldValue); const newValue = oldValue + amount * 10; // Call the handler await handlers.occupiedHeatingSetpoint?.(newValue, oldValue); // (status update will set the OccupiedHeatingSetpoint attribute) } else { throw new StatusResponse.InvalidCommandError(`Unsupported SetpointRaiseLowerMode ${mode}`); } }); } // Subscribe to attribute updates async subscribeAttributes(endpoint, cluster, name, handlers) { const handlersList = Object.entries(handlers); for (const { attribute, handler } of handlersList) { if (!handler) continue; const description = `${name} ${attribute.toString()}`; // Wrapper around handler to trap errors and flush the change cache const wrapper = (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 handler(newValue, oldValue); this.updateNextStatus(); } catch (err) { logError(this.log, description, err); } })(); }; // Register the handler const success = await endpoint.subscribeAttribute(cluster, attribute, wrapper, 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(onOffBehavior, '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(fanControlBehavior, 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(thermostatBehavior, attribute, thermostat[attribute], this.log); } } // Update the HEPA and Activated Carbon Filter Monitoring cluster attributes async updateFilterMonitoring(filters) { const updateCluster = async (cluster, 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(cluster, 'condition', condition, this.log); await endpoint?.updateAttribute(cluster, 'changeIndication', changeIndication, this.log); if (inPlaceIndicator) await endpoint?.updateAttribute(cluster, 'inPlaceIndicator', inPlaceIndicator, this.log); }; // Update the status of both filters const { hepa, carbon } = filters; if (hepa) await updateCluster(hepaFilterMonitoringBehavior, 'HEPA', hepa); if (carbon) await updateCluster(activatedCarbonFilterMonitoringBehavior, '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 updateAttribute = (endpoints, cluster, attribute, value) => { if (value === undefined) return []; return endpoints.map(endpoint => endpoint.updateAttribute(cluster, attribute, value, this.log)); }; const updatePromises = [ updateAttribute(this.airQuality, airQualityBehavior, 'airQuality', airQuality), updateAttribute(this.temperature, temperatureMeasurementBehavior, 'measuredValue', temperature), updateAttribute(this.humidity, relativeHumidityMeasurementBehavior, 'measuredValue', humidity), updateAttribute(this.airQuality, totalVolatileOrganicCompoundsConcentrationMeasurementBehavior, 'levelValue', voc), updateAttribute(this.airQuality, carbonDioxideConcentrationMeasurementBehavior, 'measuredValue', co2), updateAttribute(this.airQuality, nitrogenDioxideConcentrationMeasurementBehavior, 'levelValue', nox), updateAttribute(this.airQuality, formaldehydeConcentrationMeasurementBehavior, 'measuredValue', hcho), updateAttribute(this.airQuality, pm25ConcentrationMeasurementBehavior, 'measuredValue', pm25), updateAttribute(this.airQuality, pm10ConcentrationMeasurementBehavior, 'measuredValue', pm10) ].flat(); 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