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
JavaScript
// 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