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.
162 lines • 6.51 kB
JavaScript
// Matterbridge plugin for Dyson robot vacuum and air treatment devices
// Copyright © 2025 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';
// 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
// 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,
'accept': 'application/json',
'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}`;
}
// Issue a request and validate the JSON formatted response
async request(checker, method, path, body) {
// Issue the request
const { headers } = this;
const request = { method, path, headers };
if (body)
request.body = JSON.stringify(body);
const text = await this.requestCore(request);
// Parse the response as JSON
let json;
try {
json = JSON.parse(text);
}
catch (err) {
this.logCheckerValidation("error" /* LogLevel.ERROR */, request, text);
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse Dyson cloud API response as JSON: ${message}`);
}
// 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;
}
// Perform the request and return the response body
async requestCore(request) {
const logPrefix = `Dyson cloud API #${this.requestCount++}:`;
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 text;
try {
response = await this.client.request(request);
this.logHeaders(`${logPrefix} Response`, response.headers);
text = await response.body.text();
this.logBody(`${logPrefix} Response`, text);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
status = `ERROR: ${message}`;
throw new Error(`Failed to issue Dyson cloud API request: ${message}`);
}
// 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 text;
}
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