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.

240 lines 10.1 kB
// Matterbridge plugin for Dyson robot vacuum and air treatment devices // Copyright © 2025-2026 Alexander Thoukydides import { Client } from 'undici'; import { columns, getValidationTree, MS } from './utils.js'; import { INSPECT_VERBOSE } from './logger-options.js'; import { inspect } from 'util'; import { STATUS_CODES } from 'http'; import { PLUGIN_NAME, PLUGIN_VERSION } from './settings.js'; import { DysonCloudStatusCodeError } from './dyson-cloud-error.js'; import { setTimeout } from 'node:timers/promises'; // Base URL for the Dyson cloud API const DYSON_API_URL_GLOBAL = 'https://appapi.cp.dyson.com'; const DYSON_API_URL_CHINA = 'https://appapi.cp.dyson.cn'; // User agent string const USER_AGENT = `${PLUGIN_NAME}/${PLUGIN_VERSION}`; // Timeout for all requests const TIMEOUT = 10 * MS; // 10 seconds // Delays between retries const RETRY_DELAY_MIN = 1 * MS; // 1 second const RETRY_DELAY_MAX = 5 * 60 * MS; // 5 minutes const RETRY_DELAY_FACTOR = 2; // Dyson cloud API user agent export class DysonCloudAPIUserAgent { log; config; china; // HTTP client used to issue the requests client; // Headers to include in all requests headers = { 'user-agent': USER_AGENT, 'content-type': 'application/json' }; // Number of requests that have been issued requestCount = 0; // Construct a new Dyson cloud API user agent constructor(log, config, china) { this.log = log; this.config = config; this.china = china; // Create an HTTP client this.client = new Client(china ? DYSON_API_URL_CHINA : DYSON_API_URL_GLOBAL, { bodyTimeout: TIMEOUT, headersTimeout: TIMEOUT, connect: { timeout: TIMEOUT } }); } // Set the Bearer token setBearerToken(token) { this.headers.Authorization = `Bearer ${token}`; } // Requests that expect an empty response put(path, body) { return this.requestEmpty('PUT', path, body); } async requestEmpty(method, path, body) { // Issue the request const { headers } = this; const consume = async (response) => { await response.body.dump(); return Number(response.headers['content-length']); }; const request = { method, path, headers, consume }; if (body) request.body = JSON.stringify(body); const contentLength = await this.requestWithRetries(request); // Check that the response is empty if (contentLength) { this.logCheckerValidation("error" /* LogLevel.ERROR */, request); throw new Error(`Unexpected non-empty Dyson cloud API response (${contentLength} bytes)`); } } // Issue a request and validate the JSON formatted response /* eslint-disable max-len */ getJSON(checker, path) { return this.requestJSON(checker, 'GET', path); } postJSON(checker, path, body) { return this.requestJSON(checker, 'POST', path, body); } /* eslint-enable max-len */ async requestJSON(checker, method, path, body) { // Issue the request const headers = { ...this.headers, accept: 'application/json' }; const consume = (response) => response.body.text(); const request = { method, path, headers, consume }; if (body) request.body = JSON.stringify(body); const text = await this.requestWithRetries(request); // Parse the response as JSON let json; try { json = JSON.parse(text); } catch (cause) { this.logCheckerValidation("error" /* LogLevel.ERROR */, request, text); const message = cause instanceof Error ? cause.message : String(cause); throw new Error(`Failed to parse Dyson cloud API response as JSON: ${message}`, { cause }); } // Check that the response has the expected fields checker.setReportedPath('response'); const validation = checker.validate(json); if (validation) { this.logCheckerValidation("error" /* LogLevel.ERROR */, request, json, validation); throw new Error('Unexpected structure of Dyson cloud API response'); } const strictValidation = checker.strictValidate(json); if (strictValidation) { this.logCheckerValidation("warn" /* LogLevel.WARN */, request, json, strictValidation); // (Continue processing responses that include unexpected properties) } // Return the result return json; } // Requests that expect a binary response getBinary(path, accept) { return this.requestBinary('GET', path, accept); } async requestBinary(method, path, accept, body) { // Issue the request const headers = { ...this.headers, accept }; const consume = (response) => response.body.arrayBuffer(); const request = { method, path, headers, consume }; if (body) request.body = JSON.stringify(body); const arrayBuffer = await this.requestWithRetries(request); // Return the result, converted to a Node.js Buffer return Buffer.from(arrayBuffer); } // Perform the request, retrying if required, returning the response body async requestWithRetries(request) { // Request counters let requestCount; let retryCount = 0; let retryDelay = RETRY_DELAY_MIN; for (;;) { try { // Attempt the request requestCount ??= ++this.requestCount; const counter = `${requestCount}` + (retryCount ? `.${retryCount}` : ''); return await this.requestCore(`Dyson cloud API #${counter}:`, request); } catch (err) { // Request failed, so check whether it can be retried if (!this.canRetry(err)) throw err; ++retryCount; // Delay before trying again await setTimeout(retryDelay); retryDelay = Math.min(retryDelay * RETRY_DELAY_FACTOR, RETRY_DELAY_MAX); } } } // Decide whether a request can be retried following an error canRetry(err) { // Do not retry the request unless the failure was an API error if (!(err instanceof DysonCloudStatusCodeError)) return false; // Some status codes never retried const noRetryStatusCodes = [401, 404, 429]; if (noRetryStatusCodes.includes(err.statusCode)) { this.log.warn(`Request will not be retried (status code ${err.statusCode})`); return false; } // The request can be retried return true; } // Perform the request and return the response body async requestCore(logPrefix, request) { const startTime = Date.now(); let status = 'OK'; try { // Log the request details this.log.debug(`${logPrefix} ${request.method} ${request.path}`); this.logHeaders(`${logPrefix} Request`, this.headers); this.logBody(`${logPrefix} Request`, request.body); // Attempt to issue the request and retrieve the response let response; let body; try { response = await this.client.request(request); this.logHeaders(`${logPrefix} Response`, response.headers); body = await request.consume(response); this.logBody(`${logPrefix} Response`, body); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); status = `ERROR: ${message}`; throw new Error(`Failed to issue Dyson cloud API request: ${message}`, { cause }); } // Check whether the request was successful const statusCode = response.statusCode; status = `${statusCode} ${STATUS_CODES[statusCode]}`; if (statusCode < 200 || 300 <= statusCode) { throw new DysonCloudStatusCodeError(statusCode); } // Return the response body return body; } finally { // Log completion of the request this.log.debug(`${logPrefix} ${status} +${Date.now() - startTime}ms`); } } // Log request or response headers logHeaders(name, headers) { if (!this.config.debugFeatures.includes('Log API Headers')) return; const rows = []; Object.keys(headers).sort().forEach(key => { const values = headers[key]; if (typeof values === 'string') rows.push([`${key}:`, values]); else if (Array.isArray(values)) { values.forEach(value => rows.push([`${key}:`, value])); } }); this.log.debug(`${name} headers:`); columns(rows).forEach(line => { this.log.debug(` ${line}`); }); } // Log request or response body logBody(name, body) { if (!this.config.debugFeatures.includes('Log API Bodies')) return; if (typeof body !== 'string') return; if (body.length) { this.log.debug(`${name} body:`); body.split('\n').forEach(line => { this.log.debug(` ${line}`); }); } else { this.log.debug(`${name} body: EMPTY`); } } // Log checker validation errors logCheckerValidation(level, request, body, errors) { this.log.log(level, `${request.method} ${request.path}:`); if (errors) { const validationLines = getValidationTree(errors); validationLines.forEach(line => { this.log.log(level, line); }); } const bodyLines = inspect(body, INSPECT_VERBOSE).split('\n'); bodyLines.forEach(line => { this.log.info(` ${line}`); }); } } //# sourceMappingURL=dyson-cloud-api-ua.js.map