homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
303 lines • 11.2 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023 Alexander Thoukydides
import { setImmediate as setImmediateP } from 'timers/promises';
import { OperationState } from '../api-value-types.js';
import { APIStatusCodeError } from '../api-errors.js';
// Base class for a mock appliance
export class MockAppliance {
// Create a new API object
constructor(log) {
this.log = log;
// Optional appliance details
this.brand = 'Mock';
this.status = [];
this.settings = [];
this.commands = [];
this.programs = [];
// Other appliance state
this.connected = true;
// Event stream
this.dataEventQueue = { STATUS: [], NOTIFY: [], EVENT: [] };
this.emitEventPromise = new Promise(resolve => { this.emitEventResolve = resolve; });
}
// Derived identifiers for this appliance
get vib() { return this.enumber.split('/')[0] ?? ''; }
get haid() { return `${this.brand.toUpperCase()}-${this.vib}-0123456789AB`; }
// Get details of the mock appliance
getAppliance() {
return {
brand: this.brand,
connected: this.connected,
enumber: this.enumber,
haId: this.haid,
name: this.name ?? `Mock ${this.type}`,
type: this.type,
vib: this.vib
};
}
// Get all programs
getPrograms() {
const getProgramPartial = (op) => {
try {
return op();
}
catch {
return {};
}
};
return {
programs: this.programs.map(p => ({
key: p.key,
name: p.name,
constraints: {
available: true,
execution: 'selectandstart'
}
})),
selected: getProgramPartial(() => this.getSelectedProgram()),
active: getProgramPartial(() => this.getActiveProgram())
};
}
// Get a list of the available programs
getAvailablePrograms() {
return this.getPrograms();
}
// Get the details of a specific available programs
getAvailableProgram(key) {
const program = this.programs.find(p => p.key === key);
if (!program)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedProgram', key);
return program;
}
// Get the program which is currently being executed
getActiveProgram() {
if (this.isOperationState('Inactive', 'Ready') || !this.program)
throw MockAppliance.statusCodeError(404, 'SDK.Error.NoProgramActive');
return this.program;
}
// Start a specified program
setActiveProgram(key, options = []) {
this.setProgramWithDefaultOptions(key, options);
this.emitNotifyEvent('BSH.Common.Root.ActiveProgram', key);
this.setStatus('BSH.Common.Status.OperationState', OperationState.Run);
}
// Stop the active program
stopActiveProgram() {
this.setStatus('BSH.Common.Status.OperationState', OperationState.Ready);
this.emitNotifyEvent('BSH.Common.Root.ActiveProgram', null);
}
// Get all options of the active program
getActiveProgramOptions() {
return this.getActiveProgram().options ?? [];
}
// Set all options of the active program
setActiveProgramOptions(options) {
for (const option of options) {
const activeOption = this.getActiveProgramOption(option.key);
activeOption.value = option.value;
this.emitNotifyEvent(option.key, option.value);
}
}
// Get a specific option of the active program
getActiveProgramOption(key) {
const option = this.getActiveProgramOptions().find(o => o.key === key);
if (!option)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedOption', key);
return option;
}
// Set a specific option of the active program
setActiveProgramOption(key, value) {
const selectedOption = this.getActiveProgramOption(key);
selectedOption.value = value;
this.emitNotifyEvent(key, value);
}
// Get the program which is currently selected
getSelectedProgram() {
if (!this.program)
throw MockAppliance.statusCodeError(404, 'SDK.Error.NoProgramSelected');
return this.program;
}
// Select a program
setSelectedProgram(key, options) {
this.setProgramWithDefaultOptions(key, options);
this.emitNotifyEvent('BSH.Common.Root.SelectedProgram', key);
}
// Get all options of the selected program
getSelectedProgramOptions() {
return this.getSelectedProgram().options ?? [];
}
// Set all options of the selected program
setSelectedProgramOptions(options) {
for (const option of options) {
const selectedOption = this.getSelectedProgramOption(option.key);
selectedOption.value = option.value;
this.emitNotifyEvent(option.key, option.value);
}
}
// Get a specific option of the selected program
getSelectedProgramOption(key) {
const option = this.getSelectedProgramOptions().find(o => o.key === key);
if (!option)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedOption', key);
return option;
}
// Set a specific option of the selected program
setSelectedProgramOption(key, value) {
const selectedOption = this.getSelectedProgramOption(key);
selectedOption.value = value;
this.emitNotifyEvent(key, value);
}
// Select a program and apply its initial options
setProgramWithDefaultOptions(key, options = []) {
// First check whether the supplied options are valid
const programOptions = this.getAvailableProgram(key).options ?? [];
for (const option of options) {
const programOption = programOptions.some(o => o.key === option.key);
if (!programOption)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedOption', option.key);
}
// Select the program
this.program = { key, options: [] };
this.emitNotifyEvent('BSH.Common.Root.SelectedProgram', key);
// Set its options
this.program.options = programOptions.map((option) => {
const requested = options.find(o => o.key === option.key);
let value = requested?.value ?? option.constraints?.default;
switch (option.type) {
case 'Double':
case 'Int':
value ?? (value = option.constraints?.min ?? 0);
break;
case 'Boolean':
value ?? (value = false);
break;
default: value ?? (value = option.constraints?.allowedvalues?.[0] ?? '');
}
if (value !== undefined)
this.emitNotifyEvent(option.key, value);
return {
key: option.key,
name: option.name,
value: value,
unit: option.unit
};
});
}
// Get the current status
getStatus() {
return this.status;
}
// Get a specific status
getStatusSpecific(key) {
const status = this.status.find(s => s.key === key);
if (!status)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedStatus', key);
return status;
}
// Set a specific status
setStatus(key, value) {
this.log.debug(`Mock status ${key} <= ${String(value)}`);
const status = this.getStatusSpecific(key);
status.value = value;
this.emitStatusEvent(key, value);
}
// Get all settings
getSettings() {
return this.settings;
}
// Get a specific setting
getSetting(key) {
const setting = this.settings.find(s => s.key === key);
if (!setting)
throw MockAppliance.statusCodeError(409, 'SDK.Error.UnsupportedSetting', key);
return setting;
}
// Set a specific setting
setSetting(key, value) {
this.log.debug(`Mock setting ${key} <= ${String(value)}`);
const setting = this.getSetting(key);
setting.value = value;
this.emitNotifyEvent(key, value);
}
// Get a list of supported commands
getCommands() {
return this.commands;
}
// Issue a command
setCommand(key) {
this.log.debug(`Mock command ${key}`);
}
// Emit a CONNECTED/DISCONNECTED/PAIRED/DEPAIRED event
emitConnectedEvent(event) {
this.log.debug(`Mock event ${event}`);
this.emitEventResolve({
event,
id: this.haid
});
}
// Emit a NOTIFY event
emitNotifyEvent(key, value) {
this.emitDataEvent('NOTIFY', key, value);
}
// Emit a STATUS event
emitStatusEvent(key, value) {
this.emitDataEvent('STATUS', key, value);
}
// Emit an EVENT event
emitEventEvent(key, value) {
this.emitDataEvent('EVENT', key, value);
}
// Emit a NOTIFY/STATUS/EVENT event
emitDataEvent(event, key, value) {
// Emit the event after collecting all pending data
const emitEvent = async () => {
await setImmediateP();
const eventWithData = {
event,
id: this.haid,
data: {
items: this.dataEventQueue[event]
}
};
this.emitEventResolve(eventWithData);
this.dataEventQueue[event] = [];
};
if (!this.dataEventQueue[event].length)
emitEvent();
// Queue the data
this.dataEventQueue[event].push({
key,
value,
timestamp: Math.floor(Date.now() / 1000),
level: 'info',
handling: 'none'
});
this.log.debug(`Mock event ${event}(${this.dataEventQueue[event].length}) ${key}=${value}`);
}
// Get events for the mock appliance
async *getEvents() {
for (;;) {
const event = await this.emitEventPromise;
this.emitEventPromise = new Promise(resolve => this.emitEventResolve = resolve);
yield event;
}
}
// Test whether the current OperationState is one of the specified values
isOperationState(...states) {
try {
const operationState = this.getStatusSpecific('BSH.Common.Status.OperationState').value;
return states.map(state => OperationState[state]).includes(operationState);
}
catch {
return false;
}
}
// Create an APIStatusCodeError with a specified key
static statusCodeError(statusCode, errorKey, itemKey = 'n/a') {
const request = { method: 'MOCK', path: itemKey };
const response = { statusCode };
const body = { error: { key: errorKey } };
return new APIStatusCodeError(request, response, JSON.stringify(body));
}
}
//# sourceMappingURL=mock-appliance.js.map