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.

212 lines 9.71 kB
// Matterbridge plugin for Dyson robot vacuum and air treatment devices // Copyright © 2025-2026 Alexander Thoukydides import { INSPECT_SINGLE_LINE, MM, MP, MR, RD, SR, ST } from './logger-options.js'; import { MS, tryListener } from './utils.js'; import { dysonMqttParse } from './dyson-mqtt-parse.js'; import { DysonMqttSubscribe } from './dyson-mqtt-subscribe.js'; import { DysonMqttConnection } from './dyson-mqtt-connect.js'; import { inspect } from 'util'; import { DysonMqttFilter } from './dyson-mqtt-filter.js'; import { AsyncEventEmitter } from './async-eventemitter.js'; import { DysonMqttClientLocal, DysonMqttClientRemote } from './dyson-mqtt-client-live.js'; import { DysonMqttClientMock } from './dyson-mqtt-client-mock.js'; import { DysonMqttCache } from './dyson-mqtt-cache.js'; ; // Minimum disconnection time before indicating device not reachable const MIN_DOWN_TIME = 5 * MS; // 5 seconds // Messages that indicate that the device is unreachable const UNREACHABLE_MESSAGES = ['GOODBYE', 'GONE-AWAY']; // Dyson MQTT client for all device types export class DysonMqtt extends AsyncEventEmitter { log; config; persist; deviceConfig; mqttConfig; // The MQTT client mqtt; mqttConnection; mqttSubscribe; mqttFilter; mqttCache; // The current status status = { reachable: false, mqttState: 'starting' }; // Construct a new MQTT client constructor(log, config, persist, deviceConfig, mqttConfig) { super({ captureRejections: true }); this.log = log; this.config = config; this.persist = persist; this.deviceConfig = deviceConfig; this.mqttConfig = mqttConfig; // Create the MQTT client if ('password' in deviceConfig) { this.mqtt = new DysonMqttClientLocal(log, config, deviceConfig); } else if ('getCredentials' in deviceConfig) { this.mqtt = new DysonMqttClientRemote(log, config, deviceConfig); } else { this.mqtt = new DysonMqttClientMock(log, config, deviceConfig); } this.mqtt.on('close', tryListener(this, () => { this.updateReachable('mqtt', false); })); // Create an MQTT connection manager this.mqttConnection = new DysonMqttConnection(log, config, this.mqtt); // Manage MQTT topic subscriptions const { rootTopic, serialNumber } = deviceConfig; this.mqttSubscribe = new DysonMqttSubscribe(log, this.mqtt, config, mqttConfig.topics, rootTopic, serialNumber); this.mqttSubscribe.on('error', err => this.emit('error', err)); this.mqttSubscribe.on('subscribed', () => { this.emit('subscribed'); this.updateReachable('mqtt', true); }); // Handle received MQTT messages this.mqttFilter = new DysonMqttFilter(log); this.mqtt.on('message', tryListener(this, (topic, payload) => { // Check the received topic and message const topicStatus = this.mqttSubscribe.checkTopic(topic); const normalise = topicStatus !== 'command'; const msg = dysonMqttParse(log, mqttConfig.messages, topic, normalise, payload); const filter = this.mqttFilter.filter(msg); // Dispatch the validated message and indicate a status update this.logPayload('receive', topic, msg, filter); if (!filter && topicStatus === 'subscribed') { this.updateReachable('msg', !UNREACHABLE_MESSAGES.includes(msg.msg)); this.emit('message', msg); this.emit('status'); } })); // Attempt to restore cached status this.mqttCache = new DysonMqttCache(log, persist, serialNumber); this.mqttCache.on('error', err => this.emit('error', err)); this.mqttCache.on('restored', cachedStatus => { if (this.status.mqttState !== 'starting') return; const newStatus = { ...cachedStatus, ...this.status, mqttState: 'startingWithCache' }; Object.assign(this.status, newStatus); this.log.info('MQTT status restored from cache'); this.emit('status'); }); } // Update the device reachability, with hysteresis on unreachable indications downTimerHandle = new Map([['msg', undefined]]); updateReachable(key, reachable) { if (reachable) { // Cancel any pending down timer for this reason const handle = this.downTimerHandle.get(key); clearTimeout(handle); this.downTimerHandle.delete(key); // Mark the device as reachable if there are no active down timers if (!this.status.reachable && this.downTimerHandle.size === 0) { this.status.reachable = true; this.emit('status'); } } else { // Only start down timer if not already running if (!this.downTimerHandle.has(key) && this.status.reachable && this.status.mqttState !== 'stopped') { this.log.debug(`Starting down timer for '${key}'`); const handle = setTimeout(() => { // Mark the device as not reachable after a delay if (this.status.reachable) { this.log.error('Unreachable'); this.status.reachable = false; this.emit('status'); } }, MIN_DOWN_TIME); this.downTimerHandle.set(key, handle); } } } // Update the initialisation status updateInitialised(initialised = true) { if (!initialised) return; if (this.status.mqttState === 'initialised' || this.status.mqttState === 'stopped') return; this.status.mqttState = 'initialised'; this.log.info('MQTT client initialisation complete'); } // Wait until the device is reachable and all initial state has been received async waitUntilInitialised(cacheFallbackDelay) { let timeoutSignal; if (cacheFallbackDelay !== undefined) timeoutSignal = AbortSignal.timeout(cacheFallbackDelay); // Normal case is to wait for MQTT initialisation while (!this.status.reachable || this.status.mqttState !== 'initialised') { try { await this.onceAsync('status', timeoutSignal); } catch (err) { if (!(err instanceof Error && err.name === 'AbortError')) throw err; // Timeout occurred, so fallback to cached status (if any) if (this.status.mqttState === 'startingWithCache' || this.status.mqttState === 'initialised') break; timeoutSignal = undefined; } } // Warn if continuing with degraded status if (this.status.mqttState === 'startingWithCache') this.log.warn('Continuing using cached MQTT device status'); if (!this.status.reachable) this.log.warn('Continuing without MQTT connection to device'); } // Stop the MQTT client async stop() { if (this.status.mqttState === 'stopped') return; // Save the current status in the cache if fully initialised if (this.status.mqttState === 'initialised') { await this.mqttCache.store(this.status); this.log.info('MQTT status saved to cache'); } // Disconnect the MQTT client this.status.mqttState = 'stopped'; await this.mqttConnection.stop(); } // Publish a command async publish(...[msg, params]) { // Construct the full message const time = new Date().toISOString(); const fullMsg = { msg, ...params, time }; const payload = JSON.stringify(fullMsg); // Publish to the command topic const topic = this.mqttSubscribe.commandTopic; this.logPayload('publish', topic, fullMsg); await this.mqtt.publishAsync(topic, payload, { qos: 1, retain: false }); } // Log received or transmitted message payloads logPayload(direction, topic, payload, filter) { if (this.config.debugFeatures.includes('Log MQTT Payloads as JSON')) { // Simple unconditional logging when plain JSON required const object = JSON.stringify(payload); this.log.debug(`MQTT ${direction}: ${object} topic '${topic}'${filter ? ` (${filter})` : ''}`); } else if (this.config.debugFeatures.includes('Log MQTT Payloads')) { // List the fixed fields first const { msg, time, ...other } = payload; const properties = [ `msg: ${direction === 'publish' ? MP : MR}'${msg}'${MM}`, `time: ${RD}'${time}'${MM}` ]; // Include the other fields from the message, unless it is a duplicate if (filter === 'duplicate') { properties.push('...'); } else { const inspectOptions = INSPECT_SINGLE_LINE; properties.push(...Object.entries(other).sort().map(([key, value]) => `${key}: ${inspect(value, inspectOptions)}`)); } // Log the message (formatting with strikethrough if dropped by filter) const object = `${MM}{ ${properties.join(', ')} }${RD}`; this.log.debug(filter ? `MQTT ${direction}: ${ST}${object}${SR} (${filter})` : `MQTT ${direction}: ${object} topic '${topic}'`); } } } //# sourceMappingURL=dyson-mqtt.js.map