UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

354 lines 15.7 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2023-2025 Alexander Thoukydides import { STATUS_CODES } from 'http'; import { Client } from 'undici'; import querystring from 'node:querystring'; import { setTimeout as setTimeoutP } from 'timers/promises'; import { PLUGIN_NAME, PLUGIN_VERSION } from './settings.js'; import { APIError, APIStatusCodeError, APIValidationError } from './api-errors.js'; import { assertIsDefined, assertIsString, columns, formatMilliseconds, getValidationTree, MS } from './utils.js'; // User agent for accessing the Home Connect API export class APIUserAgent { // Create a new user agent constructor(log, config, language) { this.log = log; this.config = config; this.language = language; // Default timeout applied to most requests this.requestTimeout = 20 * MS; // Timeout applied to event stream, must be > 55 second keep-alive this.streamTimeout = 2 * 60 * MS; // Number of requests that have been issued this.requestCount = 0; // The earliest time (milliseconds since epoch) that a request can be issued this.earliestRetry = 0; this.url = this.config.simulator ? 'https://simulator.home-connect.com' : this.config.china ? 'https://api.home-connect.cn' : 'https://api.home-connect.com'; this.defaultHeaders = { 'user-agent': `${PLUGIN_NAME}/${PLUGIN_VERSION}`, 'accept-language': this.language }; } // GET request, expecting a JSON response async get(checker, path) { const { request, response } = await this.request('GET', path, { headers: { accept: 'application/vnd.bsh.sdk.v1+json' } }); return this.getJSONResponse(checker, request, response); } // GET request, returning stream lines as an async generator async getStream(path) { const { request, response } = await this.request('GET', path, { headers: { accept: 'text/event-stream' } }); const stream = this.getSSEResponse(request, response); return { request, response, stream }; } // GET request, expecting a redirection URL async getRedirect(path) { const { request, response } = await this.request('GET', path); return this.getRedirectResponse(request, response); } // PUT request with JSON body async put(path, body) { const { request, response } = await this.request('PUT', path, { headers: { 'content-type': 'application/vnd.bsh.sdk.v1+json' }, body: JSON.stringify(body) }); return this.getEmptyResponse(request, response); } // Post request with form body, expecting a JSON response async post(checker, path, form) { const { request, response } = await this.request('POST', path, { headers: { 'content-type': 'application/x-www-form-urlencoded', 'accept': 'application/json' }, body: querystring.stringify(form) }); return this.getJSONResponse(checker, request, response); } // DELETE request, no body in request or response async delete(path) { const { request, response } = await this.request('DELETE', path); return this.getEmptyResponse(request, response); } // Check that the response is empty async getEmptyResponse(request, response) { const contentLength = Number(response.headers['content-length']); if (contentLength) throw new APIError(request, response, `Unexpected non-empty response (${contentLength} bytes)`); return Promise.resolve(); } // Retrieve a JSON response and validate it against the expected type async getJSONResponse(checker, request, response) { // Check that a JSON response was returned if (response.statusCode === 204) throw new APIError(request, response, 'Unexpected empty response (status code 204 No Content)'); const contentType = response.headers['content-type']; if (typeof contentType !== 'string' || !['application/json', 'application/vnd.bsh.sdk.v1+json'].includes(contentType.replace(/;.*$/, ''))) { const contentTypeValue = JSON.stringify(contentType); throw new APIError(request, response, `Unexpected response content-type (${contentTypeValue})`); } // Retrieve and parse the response as JSON let json; try { const text = await response.body.text(); this.logBody('Response', text); json = JSON.parse(text); } catch (cause) { throw new APIError(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 Home Connect API response', request, validation, json); throw new APIValidationError(request, response, validation); } const strictValidation = checker.strictValidate(json); if (strictValidation) { this.logCheckerValidation("warn" /* LogLevel.WARN */, 'Unexpected fields in Home Connect API response', request, strictValidation, json); } // Return the result return json; } // Retrieve a redirection URL from a response async getRedirectResponse(request, response) { // Check that the response provides a redirection URL if (response.statusCode !== 302 || !response.headers.location) { const text = await response.body.text(); this.logBody('Redirect', text); throw new APIStatusCodeError(request, response, text); } // Parse the redirection URL try { const location = response.headers.location; if (typeof location !== 'string') throw new Error('Missing "location" header in redirect'); return new URL(location); } catch (cause) { const locationValue = JSON.stringify(response.headers.location); throw new APIError(request, response, `Failed to parse redirect location ${locationValue}`, { cause }); } } // Retrieve a response as Server-Sent Events (SSE) async *getSSEResponse(request, response) { try { // Read the stream as strings const body = response.body; body.setEncoding('utf8'); body.on('end', () => { this.log.warn('EVENT STREAM END'); }); body.on('error', err => { const message = err instanceof Error ? err.message : String(err); this.log.warn(`EVENT STREAM ERROR: ${message}`); }); // Parse chunks of the stream as events let sse = {}; const decoder = new TextDecoder('utf-8'); for await (let chunk of body) { if (chunk instanceof Buffer) { chunk = decoder.decode(chunk, { stream: true }); } assertIsString(chunk); this.logBody('Stream', chunk); for (const line of chunk.split(/\r?\n/)) { if (!line.length) { // A blank line indicates the end of an event if (Object.keys(sse).length) { yield sse; sse = {}; } } else if (line.startsWith(':')) { // Comment this.log.debug(`API event stream comment "${line}"`); } else { // Field, optionally with value const matches = /^(\w+): ?(.*)$/.exec(line); const [name, value] = matches ? matches.slice(1, 3) : [line, '']; assertIsDefined(name); assertIsDefined(value); sse[name] = name in sse ? `${sse[name]}\n${value}` : value; } } } } catch (cause) { throw new APIError(request, response, 'SSE stream terminated', { cause }); } } // Construct and issue a request, retrying if appropriate async request(method, path, options) { // Request counters let requestCount; let retryCount = 0; for (;;) { try { // Apply rate limiting const retryDelay = this.retryDelay; if (0 < retryDelay) { this.log.log(retryDelay < 10 * MS ? "debug" /* LogLevel.DEBUG */ : "warn" /* LogLevel.WARN */, `Waiting ${formatMilliseconds(retryDelay)} before issuing Home Connect API request`); await setTimeoutP(retryDelay); } // Attempt the request const request = await this.prepareRequest(method, path, options); requestCount ?? (requestCount = ++this.requestCount); const counter = `${requestCount}` + (retryCount ? `.${retryCount}` : ''); const logPrefix = `Home Connect request #${counter}:`; const response = await this.requestCore(logPrefix, request); return { request, response }; } catch (err) { // Request failed, so check whether it can be retried if (!this.canRetry(err)) throw err; ++retryCount; } } } // Construct a Request async prepareRequest(method, path, options) { return Promise.resolve({ idempotent: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].includes(method), ...options, method, path, headers: { ...this.defaultHeaders, ...options?.headers } }); } // 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 APIError)) return false; // Update any rate limit if (err instanceof APIStatusCodeError && err.response?.statusCode === 429 && err.response.headers['retry-after']) { this.retryDelay = Number(err.response.headers['retry-after']) * MS; } // Some status codes are never retried const noRetryStatusCodes = [400, 403, 404, 405, 406, 409, 415]; if (err instanceof APIStatusCodeError && err.response && noRetryStatusCodes.includes(err.response.statusCode)) { this.log.debug(`Request will not be retried (status code ${err.response.statusCode})`); return false; } // Only retry methods that are idempotent if (!err.request.idempotent) { this.log.debug(`Request will not be retried (${err.request.method} is not idempotent)`); return false; } // The request can be retried return true; } // Set or get the delay between retries get retryDelay() { return this.earliestRetry - Date.now(); } set retryDelay(ms) { this.earliestRetry = Math.max(this.earliestRetry, Date.now() + ms); } // Issue a generic request async requestCore(logPrefix, request) { const startTime = Date.now(); let status = 'OK'; try { // Attempt to issue the request let response; try { // Log details of the request this.log.debug(`${logPrefix} ${request.method} ${request.path}`); this.logHeaders(`${logPrefix} Request`, request.headers); this.logBody(`${logPrefix} Request`, request.body); // Create a new client and issue the request const client = new Client(this.url, { bodyTimeout: this.streamTimeout, headersTimeout: this.streamTimeout, keepAliveTimeout: this.streamTimeout, connect: { timeout: this.requestTimeout } }); response = await client.request(request); // Log the response this.logHeaders(`${logPrefix} Response`, response.headers); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); status = `ERROR: ${message}`; throw new APIError(request, undefined, status, { cause }); } // Check whether the request was successful const statusCode = response.statusCode; status = `${statusCode} ${STATUS_CODES[statusCode]}`; if (statusCode === 302) { // Don't throw an error for 302 Found } else if (statusCode < 200 || 300 <= statusCode) { const text = await response.body.text(); this.logBody(`${logPrefix} Response`, text); const err = new APIStatusCodeError(request, response, text); if (err.simpleMessage) status += ` - ${err.simpleMessage}`; 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 = []; for (const key of Object.keys(headers).sort()) { const values = headers[key]; if (typeof values === 'string') rows.push([`${key}:`, values]); else if (Array.isArray(values)) { for (const value of values) rows.push([`${key}:`, value]); } } this.log.debug(`${name} headers:`); for (const line of columns(rows)) 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:`); for (const line of body.split('\n')) 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}:`); if (request) this.log.log(level, `${request.method} ${request.path}`); const validationLines = getValidationTree(errors); for (const line of validationLines) this.log.log(level, line); this.log.debug('Received response (reformatted):'); const jsonLines = JSON.stringify(json, null, 4).split('\n'); for (const line of jsonLines) this.log.debug(` ${line}`); } } //# sourceMappingURL=api-ua.js.map