homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
89 lines • 4.17 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2026 Alexander Thoukydides
import { EventEmitter } from 'events';
import { createCheckers } from 'ts-interface-checker';
import { formatMilliseconds } from './utils.js';
import { detached, logError } from './log-error.js';
import { APIEventStreamError, APIValidationError } from './api-errors.js';
import apiTI from './ti/api-types-ti.js';
// Checkers for API responses
const checkers = createCheckers(apiTI);
// Home Connect API event stream
export class APIEventStream extends EventEmitter {
// Create a new event stream object
constructor(log, ua) {
super({ captureRejections: true });
this.log = log;
this.ua = ua;
super.on('error', (err) => logError(this.log, 'API event', err));
// Start an event stream when the first listener registers
this.once('newListener', detached(this.log, 'Start event stream', () => this.startEventStream()));
}
// Emit events for a single appliance or all appliances
async startEventStream(haid, eventName = 'event') {
const description = `events stream for ${haid ?? 'all appliances'}`;
const path = haid ? `/api/homeappliances/${haid}/events`
: '/api/homeappliances/events';
for (;;) {
const startTime = Date.now();
const elapsed = () => formatMilliseconds(Date.now() - startTime);
try {
// Start the event stream
this.log.info(`Starting ${description}`);
const { request, response, stream } = await this.ua.getStream(path);
this.log.debug(`Started ${description} after ${elapsed()}`);
this.emit(eventName, { event: 'START' });
// Dispatch events as they are received
for await (const sse of stream) {
const event = this.parseSSEToHomeConnect(request, response, sse);
this.emit(eventName, event);
}
// Normal end of event stream
this.emit(eventName, { event: 'STOP' });
}
catch (err) {
// Stream terminated due to an error
logError(this.log, 'API event stream', err);
this.emit(eventName, { event: 'STOP', err });
}
finally {
// Log completion of the stream
this.log.debug(`Terminated ${description} after ${elapsed()}`);
}
}
}
// Parse an SSE event into a Home Connect event structure
parseSSEToHomeConnect(request, response, sse) {
const event = { ...sse };
// Attempt to parse any 'data' field as JSON
if ('data' in sse && sse.data.length) {
try {
event.data = JSON.parse(sse.data);
}
catch (cause) {
throw new APIEventStreamError(request, response, `Failed to parse JSON event data (${String(cause)})`, sse, { cause });
}
// Workaround for Home Connect API bug
if (event.data && typeof event.data === 'object'
&& 'haId' in event.data && !event.id) {
this.log.debug('Applying workaround for issue #88');
event.id = event.data.haId;
}
}
// Check that the response has the expected fields
const checker = checkers.Event;
checker.setReportedPath('event');
if (!checker.test(event)) {
const validation = checker.validate(event) ?? [];
this.ua.logCheckerValidation("error" /* LogLevel.ERROR */, 'Unexpected structure of Home Connect API event', request, validation, event);
throw new APIValidationError(request, response, validation);
}
const strictValidation = checker.strictValidate(event);
if (strictValidation) {
this.ua.logCheckerValidation("warn" /* LogLevel.WARN */, 'Unexpected fields in Home Connect API event', request, strictValidation, event);
}
// Return the event
return event;
}
}
//# sourceMappingURL=api-events.js.map