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.

296 lines 14 kB
// Matterbridge plugin for Dyson robot vacuum and air treatment devices // Copyright © 2025-2026 Alexander Thoukydides import { createDecipheriv } from 'crypto'; import { DysonAccountStatus } from './dyson-cloud-types.js'; import { checkers } from './ti/dyson-cloud-types.js'; import { DysonCloudAPI } from './dyson-cloud-api.js'; import { assertIsDefined, columns, formatMilliseconds, MS, plural } from './utils.js'; import { isSupportedModel } from './dyson-device.js'; import { DysonCloudStatusCodeError } from './dyson-cloud-error.js'; import { setTimeout } from 'node:timers/promises'; import { logError } from './log-error.js'; import { PrefixLogger } from './logger-prefix.js'; // Retry back-off timings const BACKOFF_MIN = 1 * MS; // 1 second minimum backoff const BACKOFF_MAX = 60 * MS; // 1 minute maximum backoff const BACKOFF_FACTOR = 2; // Double backoff on each failure // A Dyson cloud API interface export class DysonCloud { log; config; persist; account; // The Dyson API api; // Construct a new Dyson cloud API interface constructor(log, config, persist, account) { this.log = log; this.config = config; this.persist = persist; this.account = account; this.api = this.createApi(); } // Create a Dyson API client, with Bearer token if available async createApi() { let china = false; let token; // Attempt to use the configuration to configure the API client if (this.account) { china = this.account.china; if ('token' in this.account) { // Configuration provides the Bearer token explicitly token = this.account.token; this.log.debug(`Using configured MyDyson account token: ${token}`); } else { // Attempt to use the email and password to find a stored token const persist = await this.getPersistent(this.account, 'token'); if (persist) { token = persist.token; const age = formatMilliseconds(Date.now() - persist.created); this.log.debug(`MyDyson account authorised ${age} ago: ${token}`); } else { this.log.warn('MyDyson account requires authorisation'); } } } else { this.log.debug('No MyDyson account configuration provided'); } // Create the API client const api = new DysonCloudAPI(this.log, this.config, china, token); // Perform a dummy version read before using the API for anything else await api.getVersion(); return api; } // Construct a persistent storage key from account details getPersistentStorageKey(account, type) { const { email } = account; return `${email}:${type}`; } // Retrieve stored data for an account, if any async getPersistent(account, type) { const key = this.getPersistentStorageKey(account, type); return await this.persist.getItem(key); } // Store new data for an account async setPersistent(account, type, value) { const key = this.getPersistentStorageKey(account, type); await this.persist.setItem(key, value); } } // A Dyson cloud API interface for authorising the account export class DysonCloudAuth extends DysonCloud { account; // Construct a new Dyson cloud API interface constructor(log, config, persist, account) { super(log, config, persist, account); this.account = account; } // Start authentication async startAuth() { const { email } = this.account; const api = await this.api; // Check that the email address is registered this.log.debug(`Checking email address: ${email}`); const userStatus = await api.getUserStatus(email); if (userStatus.accountStatus !== DysonAccountStatus.Active) { throw new Error(`User account ${email} is not active`); } try { // Start authorisation this.log.debug(`Starting authorisation for ${email}`); const challengeId = await api.startAuthorisation(email); // Authorisation started, so save the challenge ID this.log.info(`Account authorisation started: ${challengeId}`); const persist = { challengeId, created: Date.now() }; await this.setPersistent(this.account, 'challenge', persist); return true; } catch (err) { if (err instanceof DysonCloudStatusCodeError && err.statusCode === 429) { // Too many requests, so check for a previous challenge const challenge = await this.getPersistent(this.account, 'challenge'); if (challenge) { const { challengeId, created } = challenge; const age = formatMilliseconds(Date.now() - created); this.log.info(`Too many requests; continuing previous authorisation started ${age} ago: ${challengeId}`); return false; } else { this.log.warn('Too many requests; no previous authorisation attempt found'); } } throw err; } } // Finish authentication async finishAuth(otpCode) { const { email, password } = this.account; const api = await this.api; // Find the matching challenge ID const challenge = await this.getPersistent(this.account, 'challenge'); if (!challenge) { throw new Error(`No authorisation challenge found for ${email}`); } // Attempt to complete authorisation const { challengeId } = challenge; this.log.debug(`Completing authorisation for ${email}: ${challengeId}`); const authorised = await api.completeAuthorisation(challengeId, email, otpCode, password); // Authorisation complete, so store the token const { token } = authorised; this.log.info(`Account authorisation complete: ${token}`); const persist = { token, created: Date.now() }; await this.setPersistent(this.account, 'token', persist); } } // A Dyson cloud API interface for remote access using account configuration export class DysonCloudRemote extends DysonCloud { // Cache of recently issued credentials cache = new Map(); // Construct a new Dyson cloud API interface constructor(log, config, persist) { super(log, config, persist, config.dysonAccount); } // Retrieve the list of devices in the account async getDevices() { // Retrieve a list of devices associated with the account const api = await this.api; const manifest = await api.getManifest(); // Extract details of the devices supported by this plugin const rows = [['Serial Number', 'Name', 'MQTT', 'Model', 'Product Name', 'Firmware', 'Status']]; const deviceConfigs = []; for (const device of manifest) { const { serialNumber, model, type, productName, connectedConfiguration } = device; const firmware = connectedConfiguration?.firmware.version; const name = device.name ?? productName; const deviceLog = new PrefixLogger(this.log, name); let status; if (isSupportedModel(type)) { assertIsDefined(device.connectedConfiguration); status = 'Supported'; const { mqttRootTopicLevel: rootTopic } = device.connectedConfiguration.mqtt; const deviceApi = api.createDeviceClient(deviceLog, device); const getCredentials = async () => this.getIoT(deviceApi); deviceConfigs.push({ name, serialNumber, rootTopic, getCredentials, api: deviceApi }); } else status = '(unsupported)'; rows.push([serialNumber, `"${name}"`, type, model, productName, firmware ?? '?', status]); } this.log.info(`${plural(manifest.length, 'device')} in account,` + ` ${plural(deviceConfigs.length, 'device')} selected:`); columns(rows).forEach(line => { this.log.info(` ${line}`); }); // Return the remote MQTT details of the selected devices return deviceConfigs; } // Retrieve the AWS IoT credentials for a single device async getIoT(api) { const { log, serialNumber } = api; let backoff = BACKOFF_MIN; for (let count = 1;; ++count) { try { // Try to retrieve the credentials, caching the result log.info(`Retrieving AWS IoT credentials (attempt #${count})`); const credentials = await api.getIoTCredentials(); this.cache.set(serialNumber, { credentials, created: Date.now() }); return credentials; } catch (err) { // Handle the error if (err instanceof DysonCloudStatusCodeError) { switch (err.statusCode) { case 401: // Unauthorised log.error('MyDyson account token expired: giving up'); throw err; case 429: { // Too many requests, so use a cached result const cached = this.cache.get(serialNumber); if (cached) { const { credentials, created } = cached; const age = formatMilliseconds(Date.now() - created); log.warn(`Too many requests; trying credentials issued ${age} ago`); return credentials; } break; } } } logError(log, 'Get IoT Credentials', err); // Delay before the next attempt log.info(`Retrying AWS IoT credential fetch in ${formatMilliseconds(backoff)}...`); await setTimeout(backoff); backoff = Math.min(backoff * BACKOFF_FACTOR, BACKOFF_MAX); } } } } // A Dyson cloud API interface for local access using account configuration export class DysonCloudLocal extends DysonCloud { // Construct a new Dyson cloud API interface constructor(log, config, persist) { super(log, config, persist, config.dysonAccount); } // Retrieve the list of devices in the account async getDevices() { // Retrieve a list of devices associated with the account const api = await this.api; const manifest = await api.getManifest(); // Attempt to find details in the manifest for each configured device const deviceConfigs = []; for (const deviceConfig of this.config.devices) { const { serialNumber } = deviceConfig; const device = manifest.find(d => d.serialNumber === serialNumber); if (device?.connectedConfiguration?.mqtt.localBrokerCredentials) { const { localBrokerCredentials, mqttRootTopicLevel: rootTopic } = device.connectedConfiguration.mqtt; const name = device.name ?? device.productName; const deviceLog = new PrefixLogger(this.log, name); const deviceApi = api.createDeviceClient(deviceLog, device); const password = decodeLocalBrokerCredentials(localBrokerCredentials).apPasswordHash; deviceConfigs.push({ ...deviceConfig, name, password, rootTopic, api: deviceApi }); } else { this.log.error(`Configured device ${serialNumber} is not in MyDyson account`); } } // Next display a summary of all devices in the account const rows = [['Serial Number', 'Name', 'MQTT', 'Model', 'Product Name', 'Firmware', 'Status']]; for (const device of manifest) { const { serialNumber, name, model, type, productName, connectedConfiguration } = device; const firmware = connectedConfiguration?.firmware.version; const matched = deviceConfigs.find(d => d.serialNumber === serialNumber); let status; if (matched) status = `= ${matched.host}:${matched.port}`; else if (isSupportedModel(type)) status = '(unconfigured)'; else status = '(unsupported)'; rows.push([serialNumber, `"${name}"`, type, model, productName, firmware ?? '?', status]); } this.log.info(`${plural(manifest.length, 'device')} in MyDyson account,` + ` ${plural(deviceConfigs.length, 'device')} selected:`); columns(rows).forEach(line => { this.log.info(` ${line}`); }); // Return the remote MQTT details of the selected devices return deviceConfigs; } } // Decode local MQTT broker credentials function decodeLocalBrokerCredentials(localBrokerCredentials) { // First decode as a base64 string const input = Buffer.from(localBrokerCredentials, 'base64'); // Next decrypt using a fixed AES key and IV const key = Uint8Array.from({ length: 32 }, (_, i) => i + 1); const iv = Buffer.alloc(16); // (all zeros) const decipher = createDecipheriv('aes-256-cbc', key, iv); const decrypted = Buffer.concat([decipher.update(input), decipher.final()]).toString(); // Finally parse the result as a JSON string const parsed = JSON.parse(decrypted); if (!checkers.DysonLocalBrokerCredentials.test(parsed)) { throw new Error(`Unexpected local broker credentials format: ${decrypted}`); } return parsed; } //# sourceMappingURL=dyson-cloud.js.map