homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
661 lines • 26.4 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2025 Alexander Thoukydides
import { EventEmitter } from 'events';
import { once } from 'node:events';
import { setTimeout as setTimeoutP } from 'timers/promises';
import { APIStatusCodeError } from './api-errors.js';
import { assertIsDefined, MS } from './utils.js';
import { logError } from './log-error.js';
import { OperationState, PowerState } from './api-value-types.js';
// Minimum event stream interruption before treated as appliance disconnected
const EVENT_DISCONNECT_DELAY = 3 * MS;
// Delay before retrying a failed read of appliance state when connected
let readAllRetryDelay = 0; // (milliseconds)
const CONNECTED_RETRY_MIN_DELAY = 5 * MS;
const CONNECTED_RETRY_MAX_DELAY = 10 * 60 * MS;
const CONNECTED_RETRY_FACTOR = 2; // (double delay on each retry)
// Blackout period for workaround implicitly setting power state
const POWERSTATE_BLACKOUT = 2 * MS;
// Convert Options from dictionary to array format
function OptionsRecordToKV(options) {
return Object.entries(options).map(([key, value]) => ({ key, value: value }));
}
// Low-level access to the Home Connect API
export class HomeConnectDevice extends EventEmitter {
log;
api;
ha;
// Database of most recently reported key-value pairs
items = {};
// Stop event stream when appliance is depaired
stopEvents = false;
// Avoid multiple connection status updates in same poll cycle
setConnectedScheduled;
// Treat extended event stream outage as an appliance disconnect
stopScheduled;
// Pending actions to read appliance state when (re)connected
readAllActions;
readAllScheduled;
readPrograms;
// Create a new API object
constructor(log, api, ha) {
super({ captureRejections: true });
this.log = log;
this.api = api;
this.ha = ha;
super.on('error', (err) => logError(this.log, 'Device event', err));
// Initial device state
this.setConnectedState(this.ha.connected);
// Disable warning for more than 10 listeners on an event
this.setMaxListeners(0);
// Workaround appliances not reliably indicating power state
this.inferPowerState();
// Start streaming events
this.processEvents();
}
// Stop event stream (and any other autonomous activity)
stop() {
this.stopEvents = true;
}
// Describe an item
describe(item) {
let description = item.key;
if ('value' in item) {
description += `=${JSON.stringify(item.value)}`;
}
if (item.unit && item.unit !== 'enum') {
description += ` ${item.unit}`;
}
return description;
}
// Update cached values and notify listeners
update(items) {
// Update cached state for all items before notifying any listeners
for (const item of items)
Object.assign(this.items, { [item.key]: item });
// Notify listeners for each item
for (const item of items) {
const description = this.describe(item);
this.log.debug(`${description} (${this.listenerCount(item.key)} listeners)`);
try {
this.emit(item.key, item.value);
}
catch (err) {
logError(this.log, `Update emit ${description}`, err);
}
}
}
// Get a cached item's value
getItem(key) {
return this.items[key]?.value;
}
// Read details about this appliance (especially its connection status)
async getAppliance() {
try {
this.requireIdentify();
const appliance = await this.api.getAppliance(this.ha.haId);
this.setConnectedState(appliance.connected);
return appliance;
}
catch (err) {
throw logError(this.log, 'GET appliance', err);
}
}
// Read current status
async getStatus() {
try {
this.requireMonitor();
const status = await this.api.getStatus(this.ha.haId);
this.update(status);
return status;
}
catch (err) {
throw logError(this.log, 'GET status', err);
}
}
// Read current settings
async getSettings() {
try {
this.requireSettings();
const settings = await this.api.getSettings(this.ha.haId);
this.update(settings);
return settings;
}
catch (err) {
throw logError(this.log, 'GET settings', err);
}
}
// Read a single setting
async getSetting(settingKey) {
try {
this.requireSettings();
const setting = await this.api.getSetting(this.ha.haId, settingKey);
this.update([setting]);
return setting;
}
catch (err) {
if (err instanceof APIStatusCodeError
&& (err.key === 'SDK.Error.UnsupportedSetting'
|| err.key === 'SDK.Simulator.InternalError')) {
// Suppress error when the setting is unsupported
return null;
}
throw logError(this.log, `GET ${settingKey}`, err);
}
}
// Write a single setting
async setSetting(settingKey, value) {
try {
this.requireSettings();
this.requireRemoteControl();
await this.api.setSetting(this.ha.haId, settingKey, value);
this.update([{ key: settingKey, value: value }]);
}
catch (err) {
throw logError(this.log, `SET ${settingKey}=${String(value)}`, err);
}
}
// Read the list of all programs
async getAllPrograms() {
try {
this.requireMonitor();
const programs = await this.api.getPrograms(this.ha.haId);
if (programs.active?.key) {
this.update([{ key: 'BSH.Common.Root.ActiveProgram',
value: programs.active.key }]);
if (programs.active.options) {
this.update(programs.active.options);
}
}
else if (programs.selected?.key) {
this.update([{ key: 'BSH.Common.Root.SelectedProgram',
value: programs.selected.key }]);
if (programs.selected.options) {
this.update(programs.selected.options);
}
}
return programs.programs;
}
catch (err) {
throw logError(this.log, 'GET programs', err);
}
}
// Read the list of currently available programs
async getAvailablePrograms() {
try {
this.requireMonitor();
const programs = await this.api.getAvailablePrograms(this.ha.haId);
if (programs.active?.key) {
this.update([{ key: 'BSH.Common.Root.ActiveProgram',
value: programs.active.key }]);
if (programs.active.options) {
this.update(programs.active.options);
}
}
else if (programs.selected?.key) {
this.update([{ key: 'BSH.Common.Root.SelectedProgram',
value: programs.selected.key }]);
if (programs.selected.options) {
this.update(programs.selected.options);
}
}
return programs.programs;
}
catch (err) {
if (err instanceof APIStatusCodeError
&& err.key === 'SDK.Error.WrongOperationState') {
// Suppress error when there are no available programs
return [];
}
throw logError(this.log, 'GET available programs', err);
}
}
// Read the options for a currently available program
async getAvailableProgram(programKey) {
try {
this.requireMonitor();
const program = await this.api.getAvailableProgram(this.ha.haId, programKey);
return program;
}
catch (err) {
throw logError(this.log, `GET available program ${programKey}`, err);
}
}
// Read the currently selected program
async getSelectedProgram() {
try {
this.requireMonitor();
const program = await this.api.getSelectedProgram(this.ha.haId);
this.update([{ key: 'BSH.Common.Root.SelectedProgram', value: program.key }]);
if (this.isOperationState('Ready') && program.options) {
// Only update options when no program is active
this.update(program.options);
}
return program;
}
catch (err) {
if (err instanceof APIStatusCodeError
&& err.key === 'SDK.Error.NoProgramSelected') {
// Suppress error when there is no selected program
return null;
}
throw logError(this.log, 'GET selected program', err);
}
}
// Select a program
async setSelectedProgram(programKey, options = {}) {
try {
this.requireControl();
this.requireRemoteControl();
const programOptions = OptionsRecordToKV(options);
await this.api.setSelectedProgram(this.ha.haId, programKey, programOptions);
this.update([{ key: 'BSH.Common.Root.SelectedProgram', value: programKey }]);
this.update(programOptions);
}
catch (err) {
throw logError(this.log, `SET selected program ${programKey}`, err);
}
}
// Read the currently active program (if any)
async getActiveProgram() {
try {
// Only request the active program if one might be active
if (!this.isOperationState('Inactive', 'Ready')) {
this.requireMonitor();
const program = await this.api.getActiveProgram(this.ha.haId);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (program === undefined)
throw new Error('Empty response');
this.update([{ key: 'BSH.Common.Root.ActiveProgram',
value: program.key }]);
if (program.options) {
this.update(program.options);
}
return program;
}
else {
const operationState = this.getItem('BSH.Common.Status.OperationState');
this.log.debug(`Ignoring GET active program in ${operationState}`);
return null;
}
}
catch (err) {
if (err instanceof APIStatusCodeError
&& err.key === 'SDK.Error.NoProgramActive') {
// Suppress error when there is no active program
return null;
}
throw logError(this.log, 'GET active program', err);
}
}
// Start a program
async startProgram(programKey, options = {}) {
try {
this.requireControl();
this.requireRemoteStart();
if (programKey === undefined) {
// Start the selected program if none specified explicitly
const selected = this.getItem('BSH.Common.Root.SelectedProgram');
if (!selected)
throw new Error('No program selected');
programKey = selected;
}
const programOptions = OptionsRecordToKV(options);
await this.api.setActiveProgram(this.ha.haId, programKey, programOptions);
this.update([{ key: 'BSH.Common.Root.ActiveProgram', value: programKey }]);
this.update(programOptions);
}
catch (err) {
throw logError(this.log, `START active program ${programKey}`, err);
}
}
// Stop a program
async stopProgram() {
try {
// No action required unless a program is active
if (this.isOperationState('DelayedStart', 'Run', 'Pause', 'ActionRequired')) {
this.requireControl();
this.requireRemoteControl();
await this.api.stopActiveProgram(this.ha.haId);
}
else {
const operationState = this.getItem('BSH.Common.Status.OperationState');
this.log.debug(`Ignoring STOP active program in ${operationState}`);
}
}
catch (err) {
throw logError(this.log, 'STOP active program', err);
}
}
// Get a list of supported commands
async getCommands() {
try {
this.requireControl();
const commands = await this.api.getCommands(this.ha.haId);
return commands;
}
catch (err) {
if (err instanceof APIStatusCodeError && err.key === '404') {
// Suppress error when the API is not supported
return [];
}
throw logError(this.log, 'GET commands', err);
}
}
// Pause or resume program
async pauseProgram(pause = true) {
const command = pause ? 'BSH.Common.Command.PauseProgram'
: 'BSH.Common.Command.ResumeProgram';
try {
this.requireControl();
this.requireRemoteControl();
await this.api.setCommand(this.ha.haId, command);
}
catch (err) {
throw logError(this.log, `COMMAND ${command}`, err);
}
}
// Open or partly open door
async openDoor(fully = true) {
const command = fully ? 'BSH.Common.Command.OpenDoor'
: 'BSH.Common.Command.PartlyOpenDoor';
try {
this.requireControl();
await this.api.setCommand(this.ha.haId, command);
}
catch (err) {
throw logError(this.log, `COMMAND ${command}`, err);
}
}
// Set a specific option of the active program
async setActiveProgramOption(optionKey, value) {
try {
this.requireControl();
this.requireRemoteControl();
await this.api.setActiveProgramOption(this.ha.haId, optionKey, value);
this.update([{ key: optionKey, value: value }]);
}
catch (err) {
throw logError(this.log, `SET ${optionKey}=${String(value)}`, err);
}
}
// Wait for the appliance to be connected
async waitConnected(immediate = false) {
let connected = immediate && this.getItem('connected');
while (!connected) {
connected = await this.onceWait('connected');
}
}
// Wait for the appliance to enter specific states
async waitOperationState(states, milliseconds) {
const waitForState = async () => {
while (!this.isOperationState(...states)) {
await this.onceWait('BSH.Common.Status.OperationState');
}
};
const waitForTimeout = async () => {
if (milliseconds !== undefined) {
await setTimeoutP(milliseconds);
throw new Error('Timeout waiting for OperationState');
}
};
return Promise.race([waitForState(), waitForTimeout()]);
}
// Workaround appliances not reliably indicating power state
inferPowerState() {
// Disable workaround for blackout period after power status updated
let blackoutScheduled;
this.on('BSH.Common.Setting.PowerState', () => {
clearTimeout(blackoutScheduled);
blackoutScheduled = setTimeout(() => {
blackoutScheduled = undefined;
}, POWERSTATE_BLACKOUT);
});
// Fake the power state when the operation state changes
this.on('BSH.Common.Status.OperationState', () => {
const powerIsOn = this.getItem('BSH.Common.Setting.PowerState') === PowerState.On;
if (this.isOperationState('Ready', 'Run') && !powerIsOn) {
if (blackoutScheduled) {
this.log.debug('Operation state implies power is on (ignored)');
}
else {
this.log.debug('Operation state implies power is on');
this.update([{ key: 'BSH.Common.Setting.PowerState', value: PowerState.On }]);
}
}
else if (this.isOperationState('Inactive') && powerIsOn) {
this.log.debug('Operation state implies power is standby or off');
this.update([{ key: 'BSH.Common.Setting.PowerState', value: PowerState.Standby }]);
}
});
}
// Update whether the appliance is currently reachable
setConnectedState(isConnected) {
// Update the internal state immediately (if known)
if (isConnected !== undefined)
this.ha.connected = isConnected;
if (isConnected === false)
this.onDisconnected();
// Only apply the most recent of multiple updates
clearImmediate(this.setConnectedScheduled);
this.setConnectedScheduled = setImmediate(() => {
// Inform clients immediately when disconnected
if (!this.ha.connected && this.getItem('connected') !== false) {
this.update([{ key: 'connected', value: false }]);
}
// Read information from this appliance when it connects
if (isConnected !== false)
this.onConnected();
});
}
// Refresh appliance information when it reconnects (or connection unknown)
onConnected() {
// No action required if already reading (or read) appliance state
if (this.readAllActions)
return;
// Construct a list of pending appliance state to read
this.readAllActions = [
'getAppliance', // (checks connected and resets error)
'getStatus',
'getSettings'
];
if (this.readPrograms)
this.readAllActions.push('getSelectedProgram', 'getActiveProgram');
// Schedule the pending reads
if (!this.readAllScheduled) {
this.log.debug((this.ha.connected ? 'Connected' : 'Might be connected')
+ ', so reading appliance state...');
this.readAllScheduled = setTimeout(() => this.readAll());
}
else {
this.log.debug('Connected, but appliance state read already pending...');
}
}
// Abort refreshing appliance information when it disconnects
onDisconnected() {
if (this.readAllActions?.length) {
this.log.debug(`Appliance disconnected; abandoning ${this.readAllActions.length} pending reads`);
}
delete this.readAllActions;
}
// Attempt to read all appliance state when connected
async readAll() {
try {
// Attempt all pending reads
while (this.readAllActions?.length) {
// Careful to avoid losing action if error or array replaced
const actions = this.readAllActions;
const methodName = actions[0];
assertIsDefined(methodName);
await this[methodName].call(this);
actions.shift();
}
// Either abandoned or finished all pending reads
delete this.readAllScheduled;
if (this.readAllActions) {
// Successfully read all appliance state
this.log.debug('Successfully read all appliance state');
readAllRetryDelay = 0;
if (this.ha.connected && !this.getItem('connected')) {
this.update([{ key: 'connected', value: true }]);
}
}
else {
// Abandoned reading appliance state due to disconnection
this.log.debug('Ignoring appliance state read due to disconnection');
}
}
catch (err) {
// Attempt to recover after an error
if (this.ha.connected) {
logError(this.log, 'Reading appliance state (will retry)', err);
readAllRetryDelay =
readAllRetryDelay
? Math.min(readAllRetryDelay * CONNECTED_RETRY_FACTOR, CONNECTED_RETRY_MAX_DELAY)
: CONNECTED_RETRY_MIN_DELAY;
this.log.debug('Still connected, so retrying appliance state'
+ ` read in ${readAllRetryDelay} seconds...`);
this.readAllScheduled =
setTimeout(() => this.readAll(), readAllRetryDelay);
}
else {
const message = err instanceof Error ? err.message : String(err);
this.log.debug(`Ignoring appliance state read due to disconnection: ${message}`);
delete this.readAllScheduled;
}
}
}
// Test whether the current OperationState is one of the specified values
isOperationState(...states) {
const operationState = this.getItem('BSH.Common.Status.OperationState');
return operationState !== undefined
&& states.map(state => OperationState[state]).includes(operationState);
}
// Ensure that the appliance is connected
requireConnected() {
if (!this.ha.connected)
throw new Error('The appliance is offline');
}
// Ensure that IdentifyAppliance scope has been authorised
requireIdentify() {
if (!this.api.hasScope('IdentifyAppliance'))
throw new Error('IdentifyAppliance scope has not been authorised');
}
// Ensure that Monitor scope has been authorised
requireMonitor() {
if (!this.hasScope('Monitor'))
throw new Error('Monitor scope has not been authorised');
this.requireConnected();
}
// Ensure that Settings scope has been authorised
requireSettings() {
if (!this.hasScope('Settings'))
throw new Error('Settings scope has not been authorised');
this.requireConnected();
}
// Ensure that Control scope has been authorised
requireControl() {
if (!this.hasScope('Control'))
throw new Error('Control scope has not been authorised');
this.requireConnected();
}
// Ensure that remote control is currently allowed
requireRemoteControl() {
if (this.getItem('BSH.Common.Status.LocalControlActive'))
throw new Error('Appliance is being manually controlled locally');
if (this.getItem('BSH.Common.Status.RemoteControlActive') === false)
throw new Error('Remote control not enabled on the appliance');
}
// Ensure that remote start is currently allowed
requireRemoteStart() {
this.requireRemoteControl();
if (this.getItem('BSH.Common.Status.RemoteControlStartAllowed')
=== false)
throw new Error('Remote start not enabled on the appliance');
}
// Check whether a particular scope has been authorised
hasScope(scope) {
return this.api.hasScope(scope)
|| this.api.hasScope(this.ha.type + '-' + scope);
}
// Enable polling of selected/active programs when connected
pollPrograms(enable = true) {
this.readPrograms = enable;
}
// Process received events
async processEvents() {
try {
for await (const event of this.api.getEvents(this.ha.haId)) {
this.eventListener(event);
if (this.stopEvents)
break;
}
}
catch (err) {
logError(this.log, 'Device events', err);
}
}
// Process a single received event
eventListener(event) {
const itemCount = 'data' in event && event.data
? ('items' in event.data ? event.data.items.length : 1) : 0;
this.log.debug(`Event ${event.event} (${itemCount} items)`);
switch (event.event) {
case 'START':
// If appliance disconnected then check its current status
clearTimeout(this.stopScheduled);
this.setConnectedState();
break;
case 'STOP':
// Disconnect appliance if too slow re-establishing event stream
this.stopScheduled = setTimeout(() => {
this.log.debug('Events may have been missed;'
+ ' treating appliance as disconnected');
this.setConnectedState(false);
}, event.err ? 0 : EVENT_DISCONNECT_DELAY);
break;
case 'PAIRED':
// Check status if an appliance is added back to the account
this.log.debug('Appliance restored to Home Connect account');
this.setConnectedState();
break;
case 'DEPAIRED':
// Immediately treat a removed appliance as disconnected
this.log.debug('Appliance removed from Home Connect account;'
+ ' treating appliance as disconnected');
this.setConnectedState(false);
break;
case 'CONNECTED':
this.log.debug('Appliance is now connected to Home Connect servers');
this.setConnectedState(true);
break;
case 'DISCONNECTED':
this.log.debug('Appliance lost connection to Home Connect servers');
this.setConnectedState(false);
break;
case 'STATUS':
case 'EVENT':
case 'NOTIFY':
this.update(event.data.items);
break;
}
}
// Install a handler for a device key-value event
on(key, listener) {
return super.on(key, listener);
}
// Uninstall a handler for a device key-value event
off(key, listener) {
return super.off(key, listener);
}
// Wait (once) for a device key-value event
async onceWait(key) {
const [value] = await once(this, key);
return value;
}
// Emit an event
emit(key, value) {
return super.emit(key, value);
}
}
//# sourceMappingURL=homeconnect-device.js.map