homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
235 lines • 12 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2025 Alexander Thoukydides
import NodePersist from 'node-persist';
import { join } from 'path';
import { setTimeout as setTimeoutP } from 'timers/promises';
import { CloudAPI } from './api.js';
import { HomeConnectDevice } from './homeconnect-device.js';
import { ApplianceCleaningRobot, ApplianceDishwasher, ApplianceDryer, ApplianceWasher, ApplianceWasherDryer } from './appliance-cleaning.js';
import { ApplianceCoffeeMaker, ApplianceCookProcessor, ApplianceHob, ApplianceHood, ApplianceMicrowave, ApplianceOven, ApplianceWarmingDrawer } from './appliance-cooking.js';
import { ApplianceAirConditioner, ApplianceFreezer, ApplianceFridgeFreezer, ApplianceRefrigerator, ApplianceWineCooler } from './appliance-cooling.js';
import { ConfigSchemaData } from './homebridge-ui/schema-data.js';
import { PLUGIN_NAME, PLATFORM_NAME, DEFAULT_CONFIG, DEFAULT_CLIENTID } from './settings.js';
import { PrefixLogger } from './logger.js';
import { assertIsDefined, deepMerge, formatList, getValidationTree, keyofChecker, MS, plural } from './utils.js';
import { logError } from './log-error.js';
import { checkDependencyVersions } from './check-versions.js';
import { HOMEBRIDGE_LANGUAGES } from './api-languages.js';
import { MockAPI } from './mock/index.js';
import { typeSuite, checkers } from './ti/config-types.js';
// Interval between updating the list of appliances
// (only 1000 API calls allowed per day, so only check once an hour)
const UPDATE_APPLIANCES_DELAY = 60 * 60 * MS;
// A Homebridge HomeConnect platform
export class HomeConnectPlatform {
platformConfig;
hb;
makeUUID;
// Custom logger
log;
// Plugin configuration with defaults applied
configPlugin;
configAppliances = {};
// Mapping from UUID to accessories and their implementations
accessories = new Map();
// Persistent storage in the Homebridge storage directory
persist;
// Data required to generate the configuration schema
schema;
// Home Connect API
homeconnect;
// Create a new HomeConnect platform object
constructor(log, platformConfig, hb) {
this.platformConfig = platformConfig;
this.hb = hb;
this.makeUUID = hb.hap.uuid.generate;
// Use a custom logger to filter-out sensitive information
this.log = new PrefixLogger(log);
// Wait for Homebridge to restore cached accessories
this.hb.on('didFinishLaunching', () => this.finishedLaunching());
}
// Restore a cached accessory
configureAccessory(accessory) {
this.accessories.set(accessory.UUID, { accessory });
}
// Update list of Home Connect appliances after cache has been restored
async finishedLaunching() {
try {
const restored = this.accessories.size;
if (restored)
this.log.info(`Restored ${restored} cached accessories`);
// Check that the dependencies and configuration
checkDependencyVersions(this.log, this.hb);
[this.configPlugin, this.configAppliances] = this.checkConfig();
// Prepare other resources required by this plugin
this.persist = await this.preparePersistentStorage();
this.schema = new ConfigSchemaData(this.log, this.persist);
this.schema.setConfig(this.platformConfig);
// Connect to the Home Connect cloud
const api = this.configPlugin.debug?.includes('Mock Appliances') ? MockAPI : CloudAPI;
this.homeconnect = new api(this.log, this.configPlugin, this.persist);
// Start polling the list of Home Connect appliances
this.updateAppliances();
}
catch (err) {
logError(this.log, 'Plugin initialisation', err);
}
}
// Check the user's configuration
checkConfig() {
// Split the configuration into plugin and appliance properties
const keyofConfigPlugin = keyofChecker(typeSuite, typeSuite.ConfigPlugin);
const select = (predicate) => Object.fromEntries(Object.entries(this.platformConfig).filter(predicate));
const configPluginPre = select(([key]) => keyofConfigPlugin.includes(key));
const configAppliances = select(([key]) => !keyofConfigPlugin.includes(key));
// Apply default values
const configPlugin = deepMerge(DEFAULT_CONFIG, configPluginPre);
const defaultClientid = DEFAULT_CLIENTID(configPlugin.simulator);
if (!configPlugin.clientid && defaultClientid)
configPlugin.clientid = defaultClientid;
if (!configPlugin.clientid && configPlugin.debug?.includes('Mock Appliances'))
configPlugin.clientid = '';
// Ensure that all required fields are provided and are of suitable types
checkers.ConfigPlugin.setReportedPath('<PLATFORM_CONFIG>');
checkers.ConfigAppliances.setReportedPath('<PLATFORM_CONFIG>');
const strictValidation = [...checkers.ConfigPlugin.strictValidate(configPlugin) ?? [],
...checkers.ConfigAppliances.strictValidate(configAppliances) ?? []];
if (!checkers.ConfigPlugin.test(configPlugin)
|| !checkers.ConfigAppliances.test(configAppliances)) {
this.log.error('Plugin unable to start due to configuration errors:');
this.logCheckerValidation("error" /* LogLevel.ERROR */, strictValidation);
throw new Error('Invalid plugin configuration');
}
// Warn of extraneous fields in the configuration
if (strictValidation.length) {
this.log.warn('Unsupported fields in plugin configuration will be ignored:');
this.logCheckerValidation("warn" /* LogLevel.WARN */, strictValidation);
}
// Check that the configured language is supported by the Home Connect API
const languageTags = Object.values(HOMEBRIDGE_LANGUAGES).flatMap(country => Object.values(country));
if (!languageTags.includes(configPlugin.language.api)) {
this.log.error('Plugin configuration specifies an unsupported API language');
this.log.info(`Supported language tags are: ${formatList(languageTags)}`);
this.logCheckerValidation("error" /* LogLevel.ERROR */);
throw new Error('Invalid plugin configuration');
}
// Use the validated configuration
if (configPlugin.debug?.includes('Log Debug as Info'))
this.log.logDebugAsInfo();
PrefixLogger.logApplianceIds = configPlugin.debug?.includes('Log Appliance IDs') ?? false;
return [configPlugin, configAppliances];
}
// Log configuration checker validation errors
logCheckerValidation(level, errors) {
const errorLines = errors ? getValidationTree(errors) : [];
for (const line of errorLines)
this.log.log(level, line);
this.log.info(`${this.hb.user.configPath()}:`);
const configLines = JSON.stringify(this.platformConfig, null, 4).split('\n');
for (const line of configLines)
this.log.info(` ${line}`);
}
// Prepare the persistent storage
async preparePersistentStorage() {
const persistDir = join(this.hb.user.storagePath(), PLUGIN_NAME, 'persist');
const persist = NodePersist.create({ dir: persistDir });
await persist.init();
return persist;
}
// Periodically update a list of Home Connect home appliances
async updateAppliances() {
for (;;) {
try {
assertIsDefined(this.homeconnect);
const appliances = await this.homeconnect.getAppliances();
await this.addRemoveAccessories(appliances);
}
catch (err) {
logError(this.log, 'Reading list of home appliances', err);
}
await setTimeoutP(UPDATE_APPLIANCES_DELAY);
}
}
// Add or remove accessories to match the available appliances
async addRemoveAccessories(appliances) {
// Update the configuration schema (if initialised)
await this.schema?.setAppliances(appliances);
// Remove any appliances that have been disabled in the configuration
const enabledAppliances = appliances.filter(ha => this.configAppliances[ha.haId]?.enabled ?? true);
// Map the appliance haId identifiers to accessory UUIDs
const uuidMap = new Map(enabledAppliances.map(ha => [this.makeUUID(ha.haId), ha]));
// Add a Homebridge accessory for each new appliance
const newAccessories = [];
uuidMap.forEach((ha, uuid) => {
// Select a constructor for this appliance
const applianceConstructor = {
// Cooking appliances
CoffeeMaker: ApplianceCoffeeMaker,
CookProcessor: ApplianceCookProcessor,
Hob: ApplianceHob,
Hood: ApplianceHood,
Microwave: ApplianceMicrowave,
Oven: ApplianceOven,
WarmingDrawer: ApplianceWarmingDrawer,
// Cleaning appliances
CleaningRobot: ApplianceCleaningRobot,
Dishwasher: ApplianceDishwasher,
Dryer: ApplianceDryer,
Washer: ApplianceWasher,
WasherDryer: ApplianceWasherDryer,
// Cooling appliances
AirConditioner: ApplianceAirConditioner,
Freezer: ApplianceFreezer,
FridgeFreezer: ApplianceFridgeFreezer,
Refrigerator: ApplianceRefrigerator,
WineCooler: ApplianceWineCooler
}[ha.type];
if (!applianceConstructor) {
this.log.warn(`Appliance type '${ha.type}' not currently supported`);
return;
}
// Convert the Home Connect haId into a Homebridge UUID
let linkage = this.accessories.get(uuid);
if (linkage) {
// A HomeKit accessory already exists for this appliance
if (linkage.implementation)
return;
this.log.debug(`Connecting accessory '${ha.name}'`);
}
else {
// New appliance, so create a matching HomeKit accessory
this.log.info(`Adding new accessory '${ha.name}'`);
const accessory = new this.hb.platformAccessory(ha.name, uuid);
linkage = { accessory };
this.accessories.set(uuid, linkage);
newAccessories.push(accessory);
}
// Construct an instance of the appliance
assertIsDefined(this.homeconnect);
const deviceLog = new PrefixLogger(this.log, ha.name);
const device = new HomeConnectDevice(deviceLog, this.homeconnect, ha);
try {
linkage.implementation = new applianceConstructor(deviceLog, this, device, linkage.accessory);
}
catch (err) {
logError(this.log, 'initialising accessory', err);
}
});
this.hb.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, newAccessories);
// Delete accessories for which there is no matching appliance
const oldAccessories = [];
this.accessories.forEach(({ accessory, implementation }, uuid) => {
if (!uuidMap.has(uuid)) {
this.log.info(`Removing accessory '${accessory.displayName}'`);
implementation?.unregister();
oldAccessories.push(accessory);
this.accessories.delete(uuid);
}
});
this.hb.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, oldAccessories);
// Log a summary
this.log.info(`Found ${plural(appliances.length, 'appliance')}`
+ ` (${newAccessories.length} added, ${oldAccessories.length} removed)`);
}
}
//# sourceMappingURL=platform.js.map