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.
222 lines • 10.5 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025 Alexander Thoukydides
import { DysonMqtt } from './dyson-mqtt.js';
import { checkers as dysonMsgCheckersAir } from './ti/dyson-air-msg-types.js';
import { formatList, tryListener } from './utils.js';
import { DysonAirCarbonFilterEnum, DysonAirErrorCodeEnum, DysonAirFanSpeed, DysonAirFaultStatus, DysonAirModuleError, DysonAirModuleWarning, DysonAirProductError, DysonAirProductWarning, DysonAirSensorValueEnum, DysonAirSleepTimerEnum, DysonAirWarningCodeEnum } from './dyson-air-types.js';
import { DysonModeReason } from './dyson-types.js';
// Configuration of a Dyson MQTT client for robot vacuums
const DYSON_MQTT_CONFIG_AIR = {
topics: {
command: '@/@/command',
subscribe: ['@/@/status/connection',
'@/@/status/current',
'@/@/status/faults']
},
messages: {
prefix: 'DysonAirMsg',
checkers: dysonMsgCheckersAir
}
};
const PRODUCT_STATE_NUMERIC_KEYS = ['hmax', 'hflr', 'filf', 'osal', 'osau', 'cdrr', 'cltr', 'humt', 'rect'];
// Dyson MQTT client for air treatment machines
export class DysonMqttAir extends DysonMqtt {
// Messages still required for initialisation
initialiseMsgs = new Set([
'CURRENT-STATE',
'ENVIRONMENTAL-CURRENT-SENSOR-DATA'
]);
// Construct a new MQTT client
constructor(log, config, device) {
super(log, config, device, DYSON_MQTT_CONFIG_AIR);
// Handle MQTT events
this.on('subscribed', tryListener(this, async () => {
// Request the current status when (re)connected
await this.publish('REQUEST-CURRENT-FAULTS', {});
await this.publish('REQUEST-CURRENT-STATE', {});
await this.publish('REQUEST-PRODUCT-ENVIRONMENT-CURRENT-SENSOR-DATA', {});
})).on('message', tryListener(this, msg => {
// Update the robot vacuum state from the received messages
this.updateStateFromMessage(msg);
this.checkIfInitialised(msg);
}));
}
// Update state from a received message
updateStateFromMessage(msg) {
switch (msg.msg) {
case 'HELLO':
this.updateProductInfo(msg);
break;
case 'STATE-CHANGE':
msg = this.convertStateChange(msg);
// (fallthrough)
case 'CURRENT-STATE':
this.updateState(msg);
break;
case 'ENVIRONMENTAL-CURRENT-SENSOR-DATA':
this.updateSensorData(msg);
break;
case 'FAULTS-CHANGE':
msg = this.convertFaultsChange(msg);
// (fallthrough)
case 'CURRENT-FAULTS':
this.updateFaults(msg);
}
}
// Check whether all required messages have been received
checkIfInitialised(msg) {
this.initialiseMsgs.delete(msg.msg);
if (!this.status.initialised && this.initialiseMsgs.size === 0) {
this.status.initialised = true;
this.log.info('MQTT client initialisation complete');
}
}
// Update hardware and software state from a received message
updateProductInfo(msg) {
this.status.version = msg.version;
}
// Convert a STATE-CHANGE message to CURRENT-STATE format
convertStateChange(msg) {
const productState = convertChangesToStatus(msg.productState);
return { ...msg, msg: 'CURRENT-STATE', productState };
}
// Convert a FAULTS-CHANGE message to CURRENT-FAULTS format
convertFaultsChange(msg) {
return {
msg: 'CURRENT-FAULTS',
time: msg.time,
productErrors: convertChangesToStatus(msg.productErrors),
productWarnings: convertChangesToStatus(msg.productWarnings),
moduleErrors: convertChangesToStatus(msg.moduleErrors),
moduleWarnings: convertChangesToStatus(msg.moduleWarnings)
};
}
// Update product state from a received message
updateState(msg) {
// Check whether the error and warning codes are known
const checkCode = (description, key, knownValues) => {
const value = msg.productState[key];
if (value === undefined || Object.values(knownValues).includes(value))
return;
this.log.warn(`Received unknown ${description}: ${value}`);
};
checkCode('error code', 'ercd', DysonAirErrorCodeEnum);
checkCode('warning code', 'wacd', DysonAirWarningCodeEnum);
// Copy everything initially, but some values will be overwritten
const { productState } = msg;
Object.assign(this.status, productState);
// Parse values that can be either numeric or enum values
this.status.cflr = this.parseNumericOrEnumValue('cflr', DysonAirCarbonFilterEnum, productState.cflr);
this.status.fnsp = this.parseNumericOrEnumValue('fnsp', DysonAirFanSpeed, productState.fnsp);
this.status.nmdv = this.parseNumericOrEnumValue('nmdv', DysonAirFanSpeed, productState.nmdv);
// Similarly for the sleep timer (not overwriting any updateSensorData value)
if (productState.sltm !== undefined) {
this.status.sltm = this.parseNumericOrEnumValue('sltm', DysonAirSleepTimerEnum, productState.sltm);
}
// Parse values that should always be numeric strings
for (const key of PRODUCT_STATE_NUMERIC_KEYS) {
const value = productState[key];
this.status[key] = value === undefined ? undefined : Number(value);
}
// Convert target temperature from Kelvin to Celsius
if (this.status.hmax)
this.status.hmax = roundedKtoC(this.status.hmax / 10);
}
// Update environmental sensor data from a received message
updateSensorData(msg) {
const parse = (field, divisor) => this.parseNumericOrEnumValue(field, DysonAirSensorValueEnum, msg.data[field], divisor);
// Convert the sensor data to numeric form with appropriate scaling
this.status.hact = parse('hact');
this.status.co2r = parse('co2r');
this.status.pact = parse('pact');
this.status.hcho = parse('hchr') ?? parse('hcho');
this.status.noxl = parse('noxl');
this.status.pm25 = parse('p25r') ?? parse('pm25');
this.status.pm10 = parse('p10r') ?? parse('pm10');
this.status.vact = parse('va10') ?? parse('vact', 1 / 11);
// Convert temperature from Kelvin to Celsius
const kelvin = parse('tact', 10);
this.status.tact = typeof kelvin === 'number' ? KtoC(kelvin) : kelvin;
// Similarly for the sleep timer (not overwriting any updateState value)
if (msg.data.sltm !== undefined) {
this.status.sltm = this.parseNumericOrEnumValue('sltm', DysonAirSleepTimerEnum, msg.data.sltm);
}
}
// Update environmental sensor data from a received message
updateFaults(msg) {
const faultKeysCheckers = [
['productErrors', DysonAirProductError],
['productWarnings', DysonAirProductWarning],
['moduleErrors', DysonAirModuleError],
['moduleWarnings', DysonAirModuleWarning]
];
// Convert each fault type to a set of active fault codes
for (const [key, knownValues] of faultKeysCheckers) {
// Identify unknown faults and active known faults
const activeFaults = new Set();
const unknownFaults = new Set();
for (const [fault, status] of Object.entries(msg[key])) {
if (!Object.values(knownValues).includes(fault))
unknownFaults.add(fault);
else if (status === DysonAirFaultStatus.Fail)
activeFaults.add(fault);
}
// Log warnings for unknown faults (both active and inactive)
if (unknownFaults.size) {
this.log.warn(`Received unknown ${key}: ${formatList([...unknownFaults])}`);
}
// Update the status with the set of active faults
this.status[key] = activeFaults;
}
}
// Publish an air treatment machine command to set the product state
commandStateSet(productState) {
// Convert values to the format required in the MQTT message
const data = {};
const mapEntry = ([key, value]) => {
if (value === undefined)
return;
let valueString;
if (typeof value === 'number') {
// Convert numeric values to four digit strings for the command
const numericValue = key === 'hmax' ? roundedCtoK(value) * 10 : value;
valueString = numericValue.toFixed(0).padStart(4, '0');
}
else {
// Enum or general string values are already the correct type
valueString = value;
}
data[key] = valueString;
};
const entries = Object.entries(productState);
entries.forEach(mapEntry);
// Publish the command
return this.publish('STATE-SET', {
'mode-reason': DysonModeReason.LocalApp,
data
});
}
// Parse strings that can be numeric or enum values, returning the number or enum value
parseNumericOrEnumValue(description, enumMap, value, divisor = 1) {
if (value === undefined || value === '')
return;
// Try parsing as a decimal natural number
if (/^\d+$/.test(value))
return Number(value) / divisor;
// Otherwise check if it is a member of the specified enum type
const expectedValues = Object.values(enumMap);
if (expectedValues.includes(value))
return value;
this.log.warn(`Received unexpected '${description}' value: ${value}`
+ ` (expected ${expectedValues.join(', ')}, or a numeric string)`);
}
}
function convertChangesToStatus(changes) {
return Object.fromEntries(Object.keys(changes).map((key) => [key, changes[key]?.[1]]));
}
// Temperature conversion (accurate version for 'tact')
export function KtoC(kelvin) { return kelvin - 273.15; }
// Temperature conversion (rounded version for 'hmax')
export function roundedKtoC(kelvin) { return kelvin - 273; }
export function roundedCtoK(celsius) { return celsius + 273; }
//# sourceMappingURL=dyson-mqtt-air.js.map