homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
714 lines • 39.1 kB
JavaScript
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2025 Alexander Thoukydides
import { setTimeout as setTimeoutP } from 'timers/promises';
import { MS, assertIsDefined, columns, formatList, plural } from './utils.js';
import { logError } from './log-error.js';
import { PowerState } from './api-value-types.js';
// Relative time option keys that can be configured as absolute times
const RELATIVE_OPTION_KEY = ['BSH.Common.Option.StartInRelative', 'BSH.Common.Option.FinishInRelative'];
// Maximum time to wait for an appliance to be ready after being switched on
const READY_TIMEOUT = 2 * 60 * MS;
// Delays when selecting programs to read their options
const READY_DELAY = 1 * MS;
// Add program support to an accessory
export function HasPrograms(Base) {
return class HasPrograms extends Base {
// Accessory services
programService = [];
// Details of all programs supported by the appliance
programs = [];
// Does the appliance support switching the power on
supportsPowerOn = true;
// Ignore program selection events when reading program details
autoSelectingPrograms = false;
// Mixin constructor
constructor(...args) {
super(...args);
// Continue initialisation asynchronously
this.asyncInitialise('Programs', this.initHasPrograms());
}
// Asynchronous initialisation
async initHasPrograms() {
// Enable polling of selected/active programs when connected
this.device.pollPrograms();
// Update the cache of programs supported by this appliance
await this.initPrograms();
this.device.on('BSH.Common.Root.SelectedProgram', programKey => this.updateSelectedProgram(programKey));
// Add the appropriate services depending on the configuration
const config = this.config.programs;
if (config && Array.isArray(config)) {
this.addConfiguredPrograms(config);
}
else {
this.addAllPrograms();
}
// Add start, stop, pause, and resume if supported by the appliance
if (this.activeService && this.device.hasScope('Control')) {
// Read the list of supported commands
const commands = await this.getCached('commands', () => this.device.getCommands());
const supports = (key) => commands.some(command => command.key === key);
const supportsPause = supports('BSH.Common.Command.PauseProgram');
const supportsResume = supports('BSH.Common.Command.ResumeProgram');
if (supportsPause && supportsResume)
this.log.info('Can pause and resume programs');
else if (supportsPause)
this.log.info('Can pause (but not resume) programs');
else if (supportsResume)
this.log.info('Can resume (but not pause) programs');
// Add start, stop, pause, and resume support as appropriate
this.addActiveProgramControl(supportsPause, supportsResume);
}
}
// Restore and populate a cache of the programs supported by the appliance
async initPrograms() {
// Retrieve any previously cached program details
this.programs = await this.cache.get('Program details') ?? [];
const count = Object.keys(this.programs).length;
if (count)
this.log.debug(`Restored details of ${count} programs`);
// Check whether the appliance supports turning power on
const setting = await this.getCached('power', () => this.device.getSetting('BSH.Common.Setting.PowerState'));
const allValues = setting?.constraints?.allowedvalues ?? [];
this.supportsPowerOn = allValues.includes(PowerState.On);
// Attempt to update the list of programs
await this.device.waitConnected(true);
await this.refreshPrograms(false);
}
// Refresh details of all programs
async refreshPrograms(active = false) {
const warnPrograms = (programs, description) => {
if (!programs.length)
return;
this.log.warn(`${programs.length} of ${plural(this.programs.length, 'program')} ${description}:`);
const fields = programs.map(program => [program.name ?? '?', `(${program.key})`]);
for (const line of columns(fields))
this.log.warn(` ${line}`);
};
try {
// Read the list of all supported programs
const all = await this.getCached('programs', () => this.device.getAllPrograms());
if (!all.length)
this.log.warn('Does not support any programs');
// Merge any previous program details with the current list
this.programs = all.map(newProgram => ({ ...this.programs.find(p => p.key === newProgram.key), ...newProgram }));
// Read the list of currently available programs (not cached)
const available = await this.device.getAvailablePrograms();
const availableKeys = available.map(p => p.key);
const unavailablePrograms = this.programs.filter(p => !availableKeys.includes(p.key));
warnPrograms(unavailablePrograms, 'advertised by appliance currently unavailable');
const unexpectedPrograms = available.filter(avail => !this.programs.some(p => p.key === avail.key));
warnPrograms(unexpectedPrograms, 'available but unexpected (not included in list of all supported programs)');
// First read programs passively (less likely to generate errors)
const passiveKeys = availableKeys.filter(key => this.programs.some(p => p.key === key && !p.selected));
if (passiveKeys.length) {
// Update details of the selected programs
this.log.info(`Passively reading options for ${passiveKeys.length} programs`);
await this.updateProgramsWithoutSelecting(passiveKeys);
}
// Actively read programs missing options (unless active refresh)
const activeKeys = availableKeys.filter(key => this.programs.some(p => p.key === key && (active || !p.selected)));
if (this.device.hasScope('Control') && activeKeys.length) {
// Check whether the appliance is in a suitable state
const problems = [];
if (!this.device.isOperationState('Inactive', 'Ready'))
problems.push('there is an active program');
if (this.device.getItem('BSH.Common.Status.LocalControlActive'))
problems.push('appliance is being controlled locally');
if (this.device.getItem('BSH.Common.Status.RemoteControlActive') === false)
problems.push('remote control is not enabled');
if (problems.length) {
this.log.warn(`Unable to actively read options for ${activeKeys.length} programs (${formatList(problems)})`);
}
else {
// Update details of the selected programs
this.log.info(`Actively reading options for ${activeKeys.length} programs`);
await this.updateProgramsSelectFirst(activeKeys);
}
}
}
catch (err) {
logError(this.log, 'Reading available programs and options', err);
}
finally {
// Regardless of what happened save the results and updated schema
await this.savePrograms();
// Summarise the results
const missingPrograms = this.programs.filter(p => !p.options);
const unselectedPrograms = this.device.hasScope('Control') ? this.programs.filter(p => p.options && !p.selected) : [];
if (missingPrograms.length || unselectedPrograms.length) {
warnPrograms(missingPrograms, 'missing options (program never available to read supported options)');
warnPrograms(unselectedPrograms, 'could not be selected (details of supported options may be unreliable)');
this.missingOptionsHelp([...missingPrograms, ...unselectedPrograms]);
}
else {
this.log.info('Finished reading available program options');
}
}
}
// Suggest how to resolve missing program options
missingOptionsHelp(programs) {
// General comments about the issue
this.log.info('This could be entirely expected if some programs are never available for use'
+ ' (e.g. some appliances require Sabbath programs to be enabled in'
+ ' the appliance setting before they can be selected)');
this.log.info('Unavailable programs may also be due to bugs in the appliance firmware or Home Connect API service');
// Appliance state that might affect reading of program options
const localControl = this.device.getItem('BSH.Common.Status.LocalControlActive');
const remoteControl = this.device.getItem('BSH.Common.Status.RemoteControlActive');
// Detailed suggestions
let stepCount = 0;
const logStep = (step, subSteps = []) => {
this.log.info(` ${++stepCount}. ${step}`);
subSteps.forEach((sub, index) => { this.log.info(` (${String.fromCharCode('a'.charCodeAt(0) + index)}) ${sub}`); });
};
this.log.info('However, if these programs should be usable then these steps may help resolve the issue:');
const preconditions = [];
preconditions.push('Replenish any consumables that are low or empty');
preconditions.push('Complete any required cleaning or other maintenance operations');
preconditions.push('Ensure that the appliance has power and that no program is active');
if (this.device.hasScope('Control') && remoteControl === false) {
preconditions.push('Enable remote control on the appliance; this setting is currently disabled)');
}
if (this.device.hasScope('Control') && localControl !== undefined) {
preconditions.push('Leave the appliance idle for a few minutes (so that it can be controlled remotely)'
+ (localControl ? '; it is currently being controlled locally' : ''));
}
logStep('Ensure that the appliance is in a suitable state to allow selection of all programs:', preconditions);
logStep('Manually select (but do not start) each program on the appliance,'
+ ' leaving the appliance idle for a couple of minutes after each selection:', programs.map(p => p.name ?? p.key.replace(/^.*\./, '')));
logStep('Trigger this plugin to re-read the details of all programs using one of these methods:', [
'Restart the Homebridge instance for this plugin',
'Invoke the HomeKit "Identify" routine for this appliance (e.g. using the "ID" button in the Eve app)'
]);
}
// Update the details of specified programs without selecting them first
async updateProgramsWithoutSelecting(programKeys) {
for (const programKey of programKeys) {
const details = await this.getCached(`program ${programKey}`, () => this.device.getAvailableProgram(programKey));
this.updateCachedProgram(details, false);
}
}
// Update the details of specified programs by selecting them first
async updateProgramsSelectFirst(programKeys) {
// Remember the original appliance state
const initialPowerState = this.device.getItem('BSH.Common.Setting.PowerState');
const initialSelectedProgram = this.device.getItem('BSH.Common.Root.SelectedProgram');
// Ignore notifications about programs being selected
this.autoSelectingPrograms = true;
// Select each program in turn and read its details
try {
for (const programKey of programKeys) {
const details = await this.getCached(`program select ${programKey}`, () => this.selectAndGetAvailableProgram(programKey));
this.updateCachedProgram(details, true);
}
}
finally {
// Best-effort attempt to restore the originally selected program
if (initialSelectedProgram
&& this.device.getItem('BSH.Common.Root.SelectedProgram') !== initialSelectedProgram) {
try {
await this.device.setSelectedProgram(initialSelectedProgram);
}
catch { /* empty */ }
}
// Best-effort attempt to restore the original power state
if (initialPowerState
&& this.device.getItem('BSH.Common.Setting.PowerState') !== initialPowerState) {
try {
await this.device.setSetting('BSH.Common.Setting.PowerState', initialPowerState);
}
catch { /* empty */ }
}
// Reenable monitoring of the selected program
this.autoSelectingPrograms = false;
}
}
// Update the cached details of a single program
updateCachedProgram(details, selected) {
// Find the cached details for this program
const program = this.programs.find(program => program.key === details.key);
if (!program)
throw new Error('Attempted to update unknown program');
if (program.selected && !selected)
throw new Error('Attempted to overwrite selected program details');
// Update the cache for this program
if (selected || !program.options || details.options?.length) {
Object.assign(program, details);
if (selected)
program.selected = true;
}
}
// Select a program and read its details
async selectAndGetAvailableProgram(programKey) {
// Switch the appliance on, if necessary
const powerState = this.device.getItem('BSH.Common.Setting.PowerState');
if (this.supportsPowerOn && powerState && powerState !== PowerState.On) {
this.log.warn('Switching appliance on to read program options');
await this.device.setSetting('BSH.Common.Setting.PowerState', PowerState.On);
await setTimeoutP(READY_DELAY);
await this.device.waitOperationState(['Ready'], READY_TIMEOUT);
await setTimeoutP(READY_DELAY);
}
// Select the program, if necessary
if (this.device.getItem('BSH.Common.Root.SelectedProgram') !== programKey) {
this.log.warn(`Temporarily selecting program ${programKey} to read its options`);
await this.device.setSelectedProgram(programKey);
}
// Read the program's options
return this.getAvailableProgram(programKey);
}
// Read the details of the currently selected program
async getAvailableProgram(programKey) {
// Read the program's options
this.requireProgramReady(programKey);
const details = await this.device.getAvailableProgram(programKey);
this.requireProgramReady(programKey);
// Return the program details
return details;
}
// Ensure that the required program is selected and ready
requireProgramReady(programKey) {
// The appliance needs to be ready to read the program details reliably
if (this.supportsPowerOn && !this.device.isOperationState('Ready'))
throw new Error('Appliance is not ready (switched on without an active program)');
if (!this.device.isOperationState('Inactive', 'Ready'))
throw new Error('Appliance is not inactive or ready (without an active program)');
// The program must be currently selected
if (this.device.getItem('BSH.Common.Root.SelectedProgram') !== programKey)
throw new Error(`Program ${programKey} is not selected`);
}
// Update the configuration schema after updating the cached programs
async savePrograms() {
// Programs can only be selected or started with Control scope
this.schema.setHasControl(this.device.ha.haId, this.device.hasScope('Control'));
// Update the list of programs and their options in the schema
this.setSchemaPrograms(this.programs);
for (const program of this.programs)
this.setSchemaProgramOptions(program);
// Cache the results
await this.cache.set('Program details', this.programs);
}
// Some appliances change their supported options when a program is selected
async updateSelectedProgram(programKey) {
try {
// Ignore if this plugin selected the program automatically
if (this.autoSelectingPrograms)
return;
// Check that there is actually a program selected
if (!programKey) {
this.log.info('No program selected');
return;
}
// Check that the program is actually supported
const supported = this.programs.some(program => program.key === programKey);
if (!supported) {
this.log.warn(`Selected program ${programKey} is not supported by the Home Connect API`);
return;
}
// Read and save the options for this program
await setTimeoutP(READY_DELAY);
const details = await this.getCached(`program select ${programKey}`, () => this.getAvailableProgram(programKey));
this.updateCachedProgram(details, true);
await this.savePrograms();
}
catch (err) {
logError(this.log, 'Reading selected program options', err);
}
}
// Add all supported programs
addAllPrograms() {
// Convert the API response to the configuration format
const config = this.programs.map(program => ({
name: this.simpleName(program.name, program.key),
key: program.key
}));
// Add a service for each supported program
this.addPrograms(config);
}
// Add the programs specified in the configuration file
addConfiguredPrograms(config) {
// Treat a single invalid entry as being an empty array
// (workaround for homebridge-config-ui-x / angular6-json-schema-form)
if (config.length === 1 && !config[0]?.name && !config[0]?.key) {
this.log.warn('Treating Invalid programs array as empty (presumably written by homebridge-config-ui-x)');
config = [];
}
// Perform some validation of the configuration
const checkedConfig = [];
const names = [];
for (const program of config) {
try {
// Check that a name and program key have both been provided
const { name, key, selectonly, options } = program;
if (!name.length)
throw new Error("No 'name' field provided for program");
if (!key.length)
throw new Error("No 'key' field provided for program");
// Check that the name is unique
if (names.includes(name))
throw new Error(`Program name '${name}' is not unique`);
names.push(name);
// Check that the program key is supported by the appliance
this.assertIsProgramKey(key);
// Finally check the program options
const checkedOptions = {};
const checkOption = ([optionKey, value]) => {
// Remove any option keys starting with underscore
if (optionKey.startsWith('_'))
return;
// Check that the option key is supported by the program
this.assertIsOptionKey(key, optionKey);
this.assertIsOptionValue(key, optionKey, value);
checkedOptions[optionKey] = value;
};
for (const option of Object.entries(options ?? {}))
checkOption(option);
checkedConfig.push({ name, key, selectonly, options: checkedOptions });
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.log.error(`Invalid program configuration ignored: ${message}\n`
+ JSON.stringify(program, null, 4));
}
}
// Add a service for each configured program
this.addPrograms(checkedConfig);
}
// Test whether a program key is supported by the appliance
assertIsProgramKey(programKey) {
if (!this.programs.some(program => program.key === programKey))
throw new Error(`Program key '${programKey}' is not supported by the appliance`);
}
// Test whether an option key is supported by the specified program
assertIsOptionKey(programKey, optionKey) {
const program = this.programs.find(program => program.key === programKey);
const options = program?.options ?? [];
if (!options.some(option => option.key === optionKey))
throw new Error(`Option key '${optionKey}' is not valid for program '${programKey}'`);
}
// Test whether an option value is supported by the specified program
assertIsOptionValue(programKey, optionKey, value) {
// Should really check something here...
const program = this.programs.find(program => program.key === programKey);
const option = (program?.options ?? []).find(option => option.key === optionKey);
if (!option)
throw new Error(`Option '${optionKey}' specified for optionless program '${programKey}'`);
// Special case for relative time values specified as an absolute time
const description = `for '${programKey}' option '${optionKey}'`;
if (this.isOptionRelative(optionKey) && typeof value === 'string') {
try {
value = this.timeToSeconds(value);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Unable to parse '${value}' as a time ${description}: ${message}`);
}
}
// Check whether the type is essentially correct
const constraints = option.constraints ?? {};
const allowedValues = constraints.allowedvalues ?? [];
switch (option.type) {
case 'Boolean':
if (typeof value !== 'boolean')
throw new Error(`Value '${value}' is not boolean ${description}`);
break;
case 'Double':
case 'Int':
if (typeof value !== 'number')
throw new Error(`Value '${value}' is not numeric ${description}`);
if ((constraints.min !== undefined && value < constraints.min)
|| (constraints.max !== undefined && constraints.max < value))
throw new Error(`Value '${value}' is outside the permitted range ${description}`);
break;
case 'String':
default: // (enumerated types)
if (typeof value !== 'string')
throw new Error(`Value '${value}' is not a string ${description}`);
if (allowedValues.length && !allowedValues.includes(value))
throw new Error(`Value '${value}' is not an allowed value ${description}`);
}
}
// Add a list of programs
addPrograms(programs) {
// Add a service for each program
this.log.info(`Adding services for ${programs.length} programs`);
const fields = programs.map(program => [program.name || '?', `(${program.key})`, program.selectonly ? 'select only' : '']);
const descriptions = columns(fields);
let prevService;
for (const program of programs) {
// Log information about this program
this.log.info(` ${descriptions.shift()}`);
const options = program.options ?? {};
for (const key of Object.keys(options))
this.log.info(` ${key}=${options[key]}`);
// Add the service for this program
const service = this.addProgram(program);
this.programService.push(service);
// Link the program services
if (this.activeService)
this.activeService.addLinkedService(service);
if (prevService)
prevService.addLinkedService(service);
prevService = service;
}
// Make the services read-only when programs cannot be controlled
const allowWrite = (write) => {
const perms = ["pr" /* Perms.PAIRED_READ */, "ev" /* Perms.NOTIFY */];
if (write)
perms.push("pw" /* Perms.PAIRED_WRITE */);
for (const service of this.programService) {
service.getCharacteristic(this.Characteristic.On).setProps({ perms });
}
};
if (programs.length && !this.device.hasScope('Control')) {
// Control of this appliance has not been authorised
this.log.warn('Programs cannot be controlled without Control scope;'
+ ' re-authorise with the Home Connect API to add the missing scope');
allowWrite(false);
}
else {
allowWrite(true);
}
}
// Add a single program
addProgram({ name, key, selectonly, options }) {
// Add a switch service for this program
const service = this.makeService(this.Service.Switch, name, `program v2 ${name}`);
// Either select the program, or start/stop the active program
service.getCharacteristic(this.Characteristic.On)
.onSet(this.onSetBoolean(async (value) => {
// Convert any absolute times to relative times in seconds
const fixedOptions = {};
const fixOption = (key) => {
assertIsDefined(options);
if (this.isOptionRelative(key)) {
fixedOptions[key] = this.timeToSeconds(options[key] ?? 0);
}
else {
fixedOptions[key] = options[key];
}
};
for (const key of Object.keys(options ?? {}))
fixOption(key);
// Select or start/stop the program as appropriate
if (selectonly) {
// Select this program and its options
if (value) {
this.log.info(`SELECT Program '${name}' (${key})`);
await this.device.setSelectedProgram(key, fixedOptions);
setImmediate(() => service.updateCharacteristic(this.Characteristic.On, false));
}
}
else {
// Attempt to start or stop the program
if (value) {
this.log.info(`START Program '${name}' (${key})`);
await this.device.startProgram(key, fixedOptions);
}
else {
this.log.info(`STOP Program '${name}' (${key})`);
await this.device.stopProgram();
}
}
}));
// Update the status
const updateHK = this.makeSerialised(active => {
const prevActive = service.getCharacteristic(this.Characteristic.On).value;
if (active !== prevActive) {
this.log.info(`Program '${name}' (${key}) ${active ? 'active' : 'inactive'}`);
service.updateCharacteristic(this.Characteristic.On, active);
}
}, false);
this.device.on('BSH.Common.Root.ActiveProgram', programKey => updateHK(programKey === key));
this.device.on('BSH.Common.Status.OperationState', () => this.device.isOperationState('Inactive', 'Ready', 'Finished') && updateHK(false));
// Return the service
return service;
}
// Add the ability to pause and resume programs
addActiveProgramControl(supportsPause = false, supportsResume = false) {
// Make the (Operation State) active On characteristic writable
// (status update is performed by the normal Operation State handler)
assertIsDefined(this.activeService);
this.activeService.getCharacteristic(this.Characteristic.On)
.setProps({ perms: ["pr" /* Perms.PAIRED_READ */, "pw" /* Perms.PAIRED_WRITE */, "ev" /* Perms.NOTIFY */] })
.onSet(this.onSetBoolean(async (value) => {
// Use pause and resume if supported in the current state
if (!value && supportsPause
&& this.device.isOperationState('DelayedStart', 'Run', 'ActionRequired')) {
this.log.info('PAUSE Program');
await this.device.pauseProgram(true);
}
else if (value && supportsResume && this.device.isOperationState('Pause')) {
this.log.info('RESUME Program');
await this.device.pauseProgram(false);
}
else {
this.log.info(`${value ? 'START' : 'STOP'} Program`);
if (value)
await this.device.startProgram();
else
await this.device.stopProgram();
}
}));
}
// Read and log details of all available programs
async identify() {
await super.identify();
// Read the supported programs and their options
await this.refreshPrograms(true);
// Log details of each program
const json = {
[this.device.ha.haId]: {
programs: this.programs.map(program => {
const config = {
name: this.simpleName(program.name, program.key),
key: program.key
};
if (this.device.hasScope('Control') && program.options) {
config.options = {};
for (const option of program.options) {
Object.assign(config.options, this.makeConfigOption(option));
}
}
return config;
})
}
};
this.log.info(`${this.programs.length} programs supported\n` + JSON.stringify(json, null, 4));
}
// Convert a program option into the configuration file format
makeConfigOption(option) {
// Pick a default value for this option
const { type, unit } = option;
const constraints = option.constraints ?? {};
const { allowedvalues } = constraints;
const value = 'value' in option ? option.value
: ('default' in constraints ? constraints.default
: ('min' in constraints ? constraints.min
: (allowedvalues ? allowedvalues[0]
: (type === 'Boolean' ? false : null))));
// Construct a comment describing the allowed values
let comment;
if (allowedvalues) {
comment = allowedvalues;
}
else if ('min' in constraints && 'max' in constraints) {
const { min, max, stepsize } = constraints;
const commentParts = [];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (type)
commentParts.push(type);
commentParts.push(`[${min}..${max}]`);
if (stepsize)
commentParts.push(`step ${stepsize}`);
if (unit)
commentParts.push(unit);
comment = commentParts.join(' ');
}
else if (type === 'Boolean') {
comment = [true, false];
}
// Alternative absolute format for relative times
if (this.isOptionRelative(option.key) && typeof comment === 'string') {
comment += ' OR Time HH:MM';
}
// Return the value and comment
return {
[option.key]: value,
['_' + option.key]: comment
};
}
// Update the configuration schema with the latest program list
setSchemaPrograms(allPrograms) {
this.schema.setPrograms(this.device.ha.haId, allPrograms.map(program => ({
name: this.makeName(program.name, program.key),
key: program.key
})));
}
// Update the configuration schema with the options for a single program
setSchemaProgramOptions(program) {
const options = program.options?.map(o => this.makeSchemaOption(o)) ?? [];
this.schema.setProgramOptions(this.device.ha.haId, program.key, options);
}
// Convert program options into the configuration schema format
makeSchemaOption(option) {
// Common mappings from Home Connect to JSON schema
const typeMap = new Map([['Double', 'number'], ['Int', 'integer'], ['Boolean', 'boolean']]);
const schema = {
key: option.key,
name: this.makeName(option.name, option.key),
type: typeMap.get(option.type) ?? 'string'
};
const constraints = option.constraints ?? {};
if ('default' in constraints)
schema.default = constraints.default;
if ('min' in constraints)
schema.minimum = constraints.min;
if ('max' in constraints)
schema.maximum = constraints.max;
if (constraints.stepsize)
schema.multipleOf = constraints.stepsize;
if (option.unit && option.unit !== 'enum')
schema.suffix = option.unit;
// Construct a mapping for enum and boolean types
if (constraints.allowedvalues) {
schema.values = constraints.allowedvalues.map((key, i) => ({
key: key,
name: this.makeName(constraints.displayvalues?.[i], key)
}));
}
// Allow an absolute time to be specified for relative times
if (this.isOptionRelative(option.key)) {
schema.type = 'string';
schema.suffix = (schema.suffix ?? '') + ' (or HH:MM absolute time)';
}
// Return the mapped option
return schema;
}
// Select a name for a program or an option
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
makeName(name, key) {
// Use any existing name unchanged
if (name)
return name;
// Remove any enum prefix and insert spaces to convert from PascalCase
return key.toString()
.replace(/^.*\./g, '')
.replace(/(?=\p{Lu}\p{Ll})|(?<=\p{Ll})(?=\p{Lu})/gu, ' ');
}
// HomeKit restricts the characters allowed in names
simpleName(name, key) {
return this.makeName(name, key)
.replace(/[^\p{L}\p{N}.' -]/ug, '')
.replace(/^[^\p{L}\p{N}]*/u, '')
.replace(/[^\p{L}\p{N}]*$/u, '');
}
// Check if an option key is a relative time
isOptionRelative(key) {
const relativeOptionKeys = [...RELATIVE_OPTION_KEY];
return relativeOptionKeys.includes(key);
}
// Convert an absolute time (HH:MM) to the number of seconds in the future
timeToSeconds(value) {
// Assume that simple integers are already relative times in seconds
if (typeof value === 'number')
return value;
if (/^\d+$/.test(value))
return parseInt(value, 10);
// Otherwise attempt to parse the value as a time
const parsed = /^(\d\d):(\d\d)$/.exec(value);
if (!parsed)
throw new Error(`Time '${value}' is not in 'HH:MM' format`);
const [hours, minutes] = parsed.slice(1, 3).map(d => parseInt(d, 10));
// Convert to seconds in the future
const now = new Date();
const then = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes);
let seconds = Math.floor((then.getTime() - now.getTime()) / MS);
if (seconds < 0)
seconds += 24 * 60 * 60;
this.log.debug(`Converted time ${value} to ${seconds} seconds`);
return seconds;
}
};
}
//# sourceMappingURL=has-programs.js.map