homebridge-aeg-robot
Version:
AEG RX9 / Electrolux Pure i9 robot vacuum plugin for Homebridge
235 lines • 10.3 kB
JavaScript
// Homebridge plugin for AEG RX 9 / Electrolux Pure i9 robot vacuum
// Copyright © 2022-2024 Alexander Thoukydides
import { STATUS_CODES } from 'http';
import { Client } from 'undici';
import { setTimeout } from 'node:timers/promises';
import { PLUGIN_NAME, PLUGIN_VERSION } from './settings.js';
import { AEGAPIError, AEGAPIStatusCodeError, AEGAPIValidationError } from './aegapi-error.js';
import { columns, getValidationTree, MS } from './utils.js';
// Base URL for Electrolux Group API
export const ELECTROLUX_GROUP_API_URL = 'https://api.developer.electrolux.one';
/* eslint-disable max-len */
// User agent for accessing the Electrolux Group API
export class AEGUserAgent {
log;
config;
// Timeout applied to all requests
timeout = 5000; // milliseconds
// Delays between retries
retryDelay = {
min: 1 * MS, // 1 second
max: 5 * 60 * MS, // 5 minutes
factor: 2.0
};
// Default headers to include in all requests
defaultHeaders;
// HTTP client used to issue the requests
client;
// Number of requests that have been issued
requestCount = 0;
// Create a new user agent
constructor(log, config) {
this.log = log;
this.config = config;
// Create an HTTP client
this.client = new Client(ELECTROLUX_GROUP_API_URL, {
bodyTimeout: this.timeout,
headersTimeout: this.timeout,
connect: {
timeout: this.timeout
}
});
// Set the default headers
this.defaultHeaders = {
'x-api-key': config.apiKey,
'User-Agent': `${PLUGIN_NAME}/${PLUGIN_VERSION}`
};
}
// Requests that expect an empty response
put(path, body, options) { return this.requestEmpty('PUT', path, options, body); }
post(path, body, options) { return this.requestEmpty('POST', path, options, body); }
async requestEmpty(...params) {
const { request, response } = await this.request(...params);
const contentLength = Number(response.headers['content-length']);
if (contentLength)
throw new AEGAPIError(request, response, `Unexpected non-empty response (${contentLength} bytes)`);
}
// Requests that expect a JSON formatted response
getJSON(checker, path, options) { return this.requestJSON(checker, 'GET', path, options, undefined); }
putJSON(checker, path, body, options) { return this.requestJSON(checker, 'PUT', path, options, body); }
postJSON(checker, path, body, options) { return this.requestJSON(checker, 'POST', path, options, body); }
async requestJSON(checker, ...params) {
const { request, response } = await this.request(...params, { Accept: 'application/json' });
// Check that the response was not empty
if (response.statusCode === 204)
throw new AEGAPIError(request, response, 'Unexpected empty response (status code 204 No Content)');
// Retrieve the response as JSON text
let text;
const contentType = response.headers['content-type'];
if (typeof contentType === 'string' && contentType.startsWith('application/json')) {
text = await response.body.text();
}
else {
throw new AEGAPIError(request, response, `Unexpected response content-type (${JSON.stringify(contentType)})`);
}
// Parse the response as JSON
let json;
try {
this.logBody('Response', text);
json = JSON.parse(text);
}
catch (cause) {
throw new AEGAPIError(request, response, `Failed to parse JSON response (${String(cause)})`, { cause });
}
// Check that the response has the expected fields
checker.setReportedPath('response');
const validation = checker.validate(json);
if (validation) {
this.logCheckerValidation("error" /* LogLevel.ERROR */, 'Unexpected structure of Electrolux Group API response', request, validation, json);
throw new AEGAPIValidationError(request, response, validation);
}
const strictValidation = checker.strictValidate(json);
if (strictValidation) {
this.logCheckerValidation("warn" /* LogLevel.WARN */, 'Unexpected fields in Electrolux Group API response', request, strictValidation, json);
}
// Return the result
return json;
}
// Construct and issue a request, retrying if appropriate
async request(method, path, options, body, headers) {
// Request counters
let requestCount;
let retryCount = 0;
let retryDelay = this.retryDelay.min;
for (;;) {
try {
// Attempt the request
const request = await this.prepareRequest(method, path, options, body, headers);
requestCount ??= ++this.requestCount;
const counter = `${requestCount}` + (retryCount ? `.${retryCount}` : '');
const response = await this.requestCore(`Electrolux Group API #${counter}:`, request);
return { request, response };
}
catch (err) {
// Request failed, so check whether it can be retried
if (!this.canRetry(err, options))
throw err;
++retryCount;
// Delay before trying again
await setTimeout(retryDelay, undefined, { signal: options?.signal });
retryDelay = Math.min(retryDelay * this.retryDelay.factor, this.retryDelay.max);
}
}
}
// Construct a Request
prepareRequest(method, path, options, body, headers) {
const request = {
method,
path,
headers: { ...this.defaultHeaders, ...headers, ...options?.headers },
idempotent: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].includes(method),
signal: options?.signal
};
if (body)
request.body = JSON.stringify(body);
return request;
}
// Decide whether a request can be retried following an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canRetry(err, options) {
// Do not retry the request unless the failure was an API error
if (!(err instanceof AEGAPIError))
return false;
// Only retry methods that are idempotent
if (!err.request.idempotent) {
this.log.warn(`Request will not be retried (${err.request.method} is not idempotent)`);
return false;
}
// Some status codes never retried (authorisation failures handled elsewhere)
const noRetryStatusCodes = [404];
if (err instanceof AEGAPIStatusCodeError && err.response
&& noRetryStatusCodes.includes(err.response.statusCode)) {
this.log.warn(`Request will not be retried (status code ${err.response.statusCode})`);
return false;
}
// The request can be retried
return true;
}
// Issue a generic request
async requestCore(logPrefix, request) {
const startTime = Date.now();
let status = 'OK';
try {
// Attempt to issue the request
let response;
try {
this.log.debug(`${logPrefix} ${request.method} ${request.path}`);
this.logHeaders(`${logPrefix} Request`, request.headers);
this.logBody(`${logPrefix} Request`, request.body);
response = await this.client.request(request);
this.logHeaders(`${logPrefix} Response`, response.headers);
}
catch (cause) {
status = `ERROR: ${String(cause)}`;
throw new AEGAPIError(request, undefined, status, { cause });
}
// Check whether the request was successful
const statusCode = response.statusCode;
status = `${statusCode} ${STATUS_CODES[statusCode]}`;
if (statusCode < 200 || 300 <= statusCode) {
const text = await response.body.text();
this.logBody(`${logPrefix} Response`, text);
const err = new AEGAPIStatusCodeError(request, response, text);
status += ` ${err.message}`;
throw err;
}
// Success, so return the response
return response;
}
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.debug.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.debug.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, message, request, errors, json) {
this.log.log(level, `${message}:`);
this.log.log(level, `${request.method} ${request.path}`);
const validationLines = getValidationTree(errors);
validationLines.forEach(line => { this.log.log(level, line); });
this.log.debug('Received response (reformatted):');
const jsonLines = JSON.stringify(json, null, 4).split('\n');
jsonLines.forEach(line => { this.log.debug(` ${line}`); });
}
}
//# sourceMappingURL=aegapi-ua.js.map