UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

331 lines 15.6 kB
// 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