homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
331 lines • 15.6 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2025 Alexander Thoukydides
import { setImmediate as setImmediateP, setTimeout as setTimeoutP } from 'timers/promises';
import { HasPower } from './has-power.js';
import { PersistCache } from './persist-cache.js';
import { MS, assertIsBoolean, assertIsDefined, assertIsNumber, assertIsString, columns, formatList, formatMilliseconds, plural } from './utils.js';
import { logError } from './log-error.js';
import { Serialised } from './serialised.js';
import { ServiceNames } from './service-name.js';
import { PrefixLogger } from './logger.js';
// Initialisation timeout
const INITIALISATION_WARN_FIRST = 10 * MS; // (10 seconds)
const INITIALISATION_WARN_INTERVAL = 5 * 60 * MS; // (5 minutes)
// A Homebridge accessory for a generic Home Connect home appliance
export class ApplianceBase {
log;
platform;
device;
accessory;
// Shortcuts to Homebridge API
Service;
Characteristic;
// Configuration for this appliance
config;
schema;
optionalFeatures = [];
// Persistent cache
cache;
cachedOperation = {};
cachedPromise = new Map();
// Asynchronous initialisation tasks
asyncInitTasks = [];
// Service naming service
serviceNames;
// Accessory services
accessoryInformationService;
obsoleteServices; // (removed after async initialisation)
// Initialise an appliance
constructor(log, platform, device, accessory) {
this.log = log;
this.platform = platform;
this.device = device;
this.accessory = accessory;
this.Service = platform.hb.hap.Service;
this.Characteristic = platform.hb.hap.Characteristic;
// Log some basic information about this appliance
this.log.info(`${device.ha.brand} ${device.ha.type} (E-Nr: ${device.ha.enumber})`);
// Configuration for this appliance
assertIsDefined(this.platform.schema);
this.schema = this.platform.schema;
this.config = platform.configAppliances[device.ha.haId] ?? {};
// Initialise the cache for this appliance
assertIsDefined(platform.persist);
this.cache = new PersistCache(log, platform.persist, device.ha.haId, platform.configPlugin.language.api);
// Remove anything created by old plugin versions that is no longer required
this.cleanupOldVersions();
// Create a service naming service
this.serviceNames = new ServiceNames(this);
// List of restored services to remove if not explicitly added
this.obsoleteServices = [...accessory.services];
// Handle the identify request
accessory.on('identify', () => this.trap('Identify', this.identify()));
// Set the Accessory Information service characteristics
this.accessoryInformationService = this.makeService(this.Service.AccessoryInformation);
this.accessoryInformationService
.setCharacteristic(this.Characteristic.Manufacturer, device.ha.brand)
.setCharacteristic(this.Characteristic.Model, device.ha.enumber)
.setCharacteristic(this.Characteristic.SerialNumber, device.ha.haId)
.setCharacteristic(this.Characteristic.FirmwareRevision, '0');
// Log connection status changes
device.on('connected', connected => {
this.log.info(connected ? 'Connected' : 'Disconnected');
});
// Wait for asynchronous initialisation to complete
this.waitAsyncInitialisation();
}
// Add an asynchronous initialisation task
asyncInitialise(name, promise) {
this.asyncInitTasks.push({ name, promise });
}
// Wait for asynchronous initialisation to complete
async waitAsyncInitialisation() {
// Wait for synchronous initialisation (subclass constructors) to finish
await setImmediateP();
// Summarise the initialisation tasks
const startTime = Date.now();
const pendingNames = this.asyncInitTasks.map(task => task.name);
this.log.debug(`Initialising ${plural(pendingNames.length, 'feature')}: ${formatList(pendingNames)}`);
// Log any initialisation errors as they occur
const failedNames = [];
const promises = this.asyncInitTasks.map(async (task) => {
try {
await task.promise;
this.log.debug(`${task.name} ready +${Date.now() - startTime}ms`);
}
catch (err) {
logError(this.log, `Initialising feature ${task.name}`, err);
failedNames.push(task.name);
}
finally {
pendingNames.splice(pendingNames.indexOf(task.name), 1);
}
});
// Wait for asynchronous initialisation to complete
const initMonitor = async () => {
await Promise.race([setTimeoutP(INITIALISATION_WARN_FIRST)]);
if (pendingNames.length) {
this.log.warn('Appliance initialisation is taking longer than expected;'
+ ' some functionality will be limited until all features are ready');
}
while (pendingNames.length) {
this.log.warn(`Waiting for ${plural(pendingNames.length, 'feature')} to finish initialising: ${formatList(pendingNames)}`);
await Promise.race([setTimeoutP(INITIALISATION_WARN_INTERVAL)]);
}
};
initMonitor();
await Promise.allSettled(promises);
// Summarise the initialisation result
const initDuration = formatMilliseconds(Date.now() - startTime);
if (failedNames.length) {
this.log.error(`Initialisation failed for ${failedNames.length} of ${plural(this.asyncInitTasks.length, 'feature')}`
+ ` (${initDuration}): ${formatList(failedNames)}`);
}
else {
this.log.info(`All features successfully initialised in ${initDuration}`);
}
// Delete any obsolete services
this.cleanupServices();
// Update the configuration schema with any optional features
this.setOptionalFeatures();
}
// Get or add a service
makeService(serviceConstructor, suffix = '', subtype) {
// Check whether the service already exists
let service = subtype
? this.accessory.getServiceById(serviceConstructor, subtype)
: this.accessory.getService(serviceConstructor);
if (service) {
// Remove from the list of obsolete services
const serviceIndex = this.obsoleteServices.indexOf(service);
if (serviceIndex !== -1)
this.obsoleteServices.splice(serviceIndex, 1);
}
else {
// Create a new service
const displayName = this.serviceNames.makeServiceName(suffix, subtype);
this.log.debug(`Adding new service "${displayName}"`);
service = this.accessory.addService(serviceConstructor, displayName, subtype);
}
// Add a Configured Name characteristic if a custom name was supplied
if (suffix.length)
this.serviceNames.addConfiguredName(service, suffix, subtype);
// Return the service
return service;
}
// Check and tidy services after the accessory has been configured
cleanupServices() {
// Remove any services that were restored from cache but no longer required
for (const service of this.obsoleteServices) {
this.log.info(`Removing obsolete service "${service.displayName}"`);
this.accessory.removeService(service);
}
}
// Tidy-up after earlier versions of this plugin
cleanupOldVersions() {
// Response cache has been moved from the accessory to node-persist
delete this.accessory.context.cache;
// Extra characteristics have previously been on the 'power' Switch
const powerService = this.accessory.getServiceById(this.Service.Switch, 'power');
if (powerService) {
const obsoleteCharacteristics = [
// Moved to the 'active' Switch in version 0.14.0
this.Characteristic.Active,
this.Characteristic.StatusActive,
this.Characteristic.StatusFault,
this.Characteristic.RemainingDuration,
// Moved to a new Door service in version 0.25.0
this.Characteristic.CurrentDoorState,
this.Characteristic.LockCurrentState
];
const removeCharacteristics = obsoleteCharacteristics
.filter(c => powerService.testCharacteristic(c))
.map(c => powerService.getCharacteristic(c));
if (removeCharacteristics.length) {
this.log.warn(`Removing ${removeCharacteristics.length} characteristics from HomeKit Switch`);
for (const characteristic of removeCharacteristics)
powerService.removeCharacteristic(characteristic);
}
}
}
// The appliance no longer exists so stop updating it
unregister() {
this.device.stop();
this.device.removeAllListeners();
}
// Identify this appliance
async identify() {
// Log the current status of this appliance
if (!PrefixLogger.logApplianceIds) {
this.log.warn('haId values are being redacted; set the "Log Appliance IDs" debug feature to reveal their full values');
}
this.log.info('Identify: ' + this.device.ha.haId);
const itemDescriptions = Object.values(this.device.items).map(item => this.device.describe(item));
for (const item of itemDescriptions.sort())
this.log.info(item);
return Promise.resolve();
}
// Check whether an optional feature should be enabled
hasOptionalFeature(service, name, group = '', enableByDefault = true) {
// Add to the list of optional features
this.optionalFeatures.push({ service, name, group, enableByDefault });
// Return whether the feature should be enabled
const enableByConfig = this.config.features?.[name];
const enabled = enableByConfig ?? enableByDefault;
this.log.info(`Optional ${group ? `${group} ` : ''}(${service} service) feature "${name}"`
+ ` ${enabled ? 'enabled' : 'disabled'} by ${enableByConfig === undefined ? 'default' : 'configuration'}`);
return enabled;
}
// Update the configuration schema with any optional features
setOptionalFeatures() {
// Log a summary of optional features
const list = (description, predicate) => {
const matched = this.optionalFeatures.filter(predicate);
if (matched.length) {
this.log.info(`${plural(matched.length, 'optional feature')} ${description}:`);
const sortBy = (feature) => `${feature.group} - ${feature.name}`;
const fields = matched.sort((a, b) => sortBy(a).localeCompare(sortBy(b)))
.map(feature => [feature.name, feature.group, `(${feature.service} service)`]);
for (const line of columns(fields))
this.log.info(` ${line}`);
}
};
const configured = (feature) => this.config.features?.[feature.name];
list('disabled by configuration', feature => configured(feature) === false);
list('enabled by configuration', feature => configured(feature) === true);
list('disabled by default (unconfigured)', feature => configured(feature) === undefined && !feature.enableByDefault);
list('enabled by default (unconfigured)', feature => configured(feature) === undefined && feature.enableByDefault);
// Update the configuration schema
this.schema.setOptionalFeatures(this.device.ha.haId, this.optionalFeatures);
}
// Query the appliance when connected and cache the result
async getCached(key, operation) {
// Check that the operation matches any other use of the same key
const previousOperation = this.cachedOperation[key];
if (previousOperation && previousOperation !== operation.toString()) {
this.log.error(`Mismatched "${key}" cache operations:`);
this.log.error(` ${previousOperation}`);
this.log.error(`!== ${String(operation)}`);
}
// Wait for any previous operation to complete
await this.cachedPromise.get(key);
// Perform the cached operation
try {
const promise = this.doCachedOperation(key, operation);
this.cachedPromise.set(key, promise);
const value = await promise;
return value;
}
finally {
this.cachedPromise.delete(key);
}
}
// Perform a cache query with fallback to querying the appliance
async doCachedOperation(key, operation) {
// Use cached result if possible
const cacheKey = `Appliance ${key}`;
const cacheItem = await this.cache.getWithExpiry(cacheKey);
if (cacheItem?.valid)
return cacheItem.value;
try {
// Wait for the appliance to connect and then attempt the operation
await this.device.waitConnected(true);
const value = await operation();
// Success, so cache and return the result
await this.cache.set(cacheKey, value);
return value;
}
catch (err) {
if (cacheItem) {
// Operation failed, so use the (expired) cache entry
const message = err instanceof Error ? err.message : String(err);
this.log.warn(`Using expired cache result: ${message}`);
return cacheItem.value;
}
else {
logError(this.log, `Cached operation '${key}'`, err);
throw (err);
}
}
}
// Coalesce and serialise operations triggered by multiple characteristics
makeSerialised(operation, defaultValue = undefined) {
const serialised = new Serialised(this.log, operation, defaultValue);
return (value) => serialised.trigger(value);
}
makeSerialisedObject(operation) {
const options = { reset: true };
const serialised = new Serialised(this.log, operation, {}, options);
return (value) => serialised.trigger(value);
}
// Wrap a Homebridge Characteristic.onSet handler
onSet(handler, assertIsType) {
return async (value) => {
try {
assertIsType(value);
await handler(value);
}
catch (err) {
logError(this.log, `onSet(${JSON.stringify(value)})`, err);
throw new this.platform.hb.hap.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
}
};
}
onSetBoolean(handler) { return this.onSet(handler, assertIsBoolean); }
onSetNumber(handler) { return this.onSet(handler, assertIsNumber); }
onSetString(handler) { return this.onSet(handler, assertIsString); }
// Wrap an operation with an error trap
async trap(when, promise, canThrow) {
try {
return await promise;
}
catch (err) {
logError(this.log, when, err);
if (canThrow)
throw err;
}
}
}
// All Homebridge appliances have power state
export const ApplianceGeneric = HasPower(ApplianceBase);
//# sourceMappingURL=appliance-generic.js.map