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.

222 lines 10.5 kB
// 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