homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
610 lines • 26.7 kB
JavaScript
/* eslint-disable max-len */
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2025 Alexander Thoukydides
import assert from 'assert';
import { HOMEBRIDGE_LANGUAGES } from '../api-languages.js';
import { assertIsDefined, keyofChecker, plural } from '../utils.js';
import { DEFAULT_CONFIG, PLATFORM_NAME } from '../settings.js';
import { ConfigSchemaData } from './schema-data.js';
import { typeSuite } from '../ti/config-types.js';
// Maximum number of enum values for numeric types with multipleOf constraint
const MAX_ENUM_STEPS = 18;
// Schema generator for the Homebridge config.json configuration file
export class ConfigSchema extends ConfigSchemaData {
// Construct a schema fragment for this plugin
getSchemaFragmentPlugin() {
const schema = {
platform: {
type: 'string',
default: PLATFORM_NAME,
required: true
},
name: {
type: 'string',
minLength: 1,
default: PLATFORM_NAME
}
};
const form = [{
key: 'name',
notitle: true,
description: 'This is used to prefix entries in the Homebridge log.'
}];
return { schema, form };
}
// Convert the supported Home Connect API languages into a schema
getSchemaHomeConnectLanguages() {
// Flatten the supported languages
const languages = [];
for (const [language, countries] of Object.entries(HOMEBRIDGE_LANGUAGES)) {
const single = Object.keys(countries).length === 1;
for (const [country, tag] of Object.entries(countries)) {
let title = language;
if (!single)
title += `: ${country} - ${language}`;
languages.push({
title: title,
const: tag
});
}
}
// Return the configuration schema for the language choices
return {
type: 'string',
default: DEFAULT_CONFIG.language?.api ?? '',
oneOf: languages,
required: true
};
}
// Construct a schema for the Home Connect Client
getSchemaFragmentClient() {
const schema = {
clientid: {
type: 'string',
minLength: 64,
maxLength: 64,
pattern: '^[0-9A-Fa-f]+$',
required: true
},
simulator: {
type: 'boolean',
default: false,
required: true
},
china: {
type: 'boolean',
default: false,
required: true
},
language: {
type: 'object',
properties: {
api: this.getSchemaHomeConnectLanguages()
}
}
};
const form = [{
key: 'simulator',
title: 'Client Type',
type: 'select',
titleMap: {
false: 'Physical Appliances (production server)',
true: 'Simulated Appliances (test server)'
}
}, {
type: 'help',
helpvalue: '<div class="help-block">Create an application via the <a href="https://developer.home-connect.com/applications">Home Connect Developer Program</a>, ensuring that:'
+ '<ul>'
+ '<li><i>OAuth Flow</i> is set to <b>Device Flow</b></li>'
+ '<li><i>Home Connect User Account for Testing</i> is the same as the <b>SingleKey ID email address</b></li>'
+ '<li><i>Redirect URI</i> is <b>left blank</b></li>'
+ '<li><i>Enable One Time Token Mode</i> is <b>not ticked</b></li>'
+ '<li><i>Sync to China</i> is <b>ticked</b> if you are located within China</li>'
+ '</ul>'
+ 'If the application is subsequently edited then additionally ensure that:'
+ '<ul>'
+ '<li><i>Forces the usage of PKCE</i> is <b>not ticked</b></li>'
+ '<li><i>Status</i> is <b>Enabled</b></li>'
+ '<li><i>Client Secret Always Required</i> is <b>No</b></li>'
+ '</ul>'
+ 'Wait 15 minutes after creating (or editing) an application for changes to the application to be deployed to the Home Connect authorisation servers.</div>',
condition: {
functionBody: 'return !model.simulator'
}
}, {
key: 'clientid',
title: 'Client ID',
description: 'Enter the Client ID of the registered Home Connect application.',
placeholder: 'e.g. 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
condition: {
functionBody: 'return !model.simulator'
}
}, {
key: 'clientid',
title: 'Client ID',
description: 'Enter the Client ID for the automatically generated <a href="https://developer.home-connect.com/applications">API Web Client</a> to use the <a href="https://developer.home-connect.com/simulator">Appliance Simulators</a>.<br>Use this to test the functionality of this plugin without requiring access to physical appliances.',
placeholder: 'e.g. 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
condition: {
functionBody: 'return model.simulator'
}
}, {
key: 'china',
title: 'Server Location',
description: 'Separate Home Connect API servers are operated within China.',
type: 'select',
titleMap: {
false: 'Worldwide (excluding China)',
true: 'China'
},
condition: {
functionBody: 'return !model.simulator'
}
}, {
key: 'language.api',
title: 'API Language',
description: 'This changes the language used for program names and their options.',
condition: {
functionBody: 'return !model.simulator && model.clientid'
}
}];
return { schema, form };
}
// Construct a schema for debug options
getSchemaFragmentDebug() {
const schema = {
debug: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: keyofChecker(typeSuite, typeSuite.DebugFeatures)
}
}
};
const form = [{
key: 'debug',
notitle: true,
description: 'Leave all options unchecked unless debugging a problem.'
}];
return { schema, form };
}
// Construct a schema for an appliance's service names
getSchemaFragmentApplianceNames(appliance) {
// Create the schema for the service name configuration
const schema = {
names: {
type: 'object',
properties: {
prefix: {
type: 'object',
properties: {
programs: {
type: 'boolean',
default: false
},
other: {
type: 'boolean',
default: true
}
}
}
}
}
};
// Create the form items for the relevant service name configuration
const namesForm = [];
const addPrefixForm = (key, title, name, enabledByDefault) => {
namesForm.push({
type: 'flex',
'flex-flow': 'column',
notitle: true,
items: [{
key: `names.prefix.${key}`,
title: title,
default: enabledByDefault,
description: `e.g. "<i>${name}</i>"`,
condition: {
functionBody: `return !model.names.prefix.${key};`
}
}, {
key: `names.prefix.${key}`,
title: title,
default: enabledByDefault,
description: `e.g. "<i>${appliance.name} ${name}</i>"`,
condition: {
functionBody: `return model.names.prefix.${key};`
}
}]
});
};
const firstProgram = appliance.programs[0];
if (firstProgram !== undefined) {
const program = firstProgram.name;
addPrefixForm('programs', 'Prefix Program Names', program, false);
addPrefixForm('other', 'Prefix Other Service Names', 'Power', true);
}
else {
addPrefixForm('other', 'Prefix Appliance Name to Services', 'Power', true);
}
// Create the top-level schema for the service names
const form = [{
type: 'flex',
notitle: true,
'flex-flow': 'row',
items: namesForm
}];
return { schema, form };
}
// Construct a schema for an appliance's optional features
getSchemaFragmentApplianceOptionalFeatures(appliance) {
var _a;
// Special case if the appliance does not have any optional features
if (!Object.keys(appliance.features).length)
return {
schema: {},
form: []
};
// Create the schema for each optional feature
const featuresSchema = {};
const groups = {};
for (const feature of appliance.features) {
featuresSchema[feature.name] = {
type: 'boolean',
default: feature.enableByDefault,
required: false
};
groups[_a = feature.group] ?? (groups[_a] = []);
groups[feature.group]?.push(feature);
}
// Arrange the optional features into groups
const groupForm = [];
for (const groupKey of Object.keys(groups).sort()) {
assertIsDefined(groups[groupKey]);
const features = groups[groupKey].sort((a, b) => a.name.localeCompare(b.name));
const featuresForm = [];
let lastService;
for (const feature of features) {
if (feature.service !== lastService) {
lastService = feature.service;
const count = features.filter(f => f.service === feature.service).length;
featuresForm.push({
type: 'help',
helpvalue: `<span class="help-block"><em>${feature.service}</em> ${plural(count, 'service', false)}:</span>`
});
}
featuresForm.push({
key: `features.${feature.name}`,
title: feature.name
});
}
// Add this group to the schema
groupForm.push({
type: 'flex',
'flex-flow': 'column',
title: `Optional ${appliance.type} ${(features[0]?.group ?? '') || 'Features'}`,
items: featuresForm
});
}
// Create the top-level schema for the optional features
const schema = {
features: {
type: 'object',
properties: featuresSchema
}
};
const form = [{
type: 'flex',
'flex-flow': 'row',
notitle: true,
items: groupForm
}];
return { schema, form };
}
// Construct a schema for an appliance's programs
getSchemaFragmentAppliancePrograms(appliance) {
var _a;
// Special case if the appliance does not support any programs
if (!appliance.programs.length)
return {
schema: {},
form: [{
type: 'help',
helpvalue: 'This appliance does not support any programs.'
}]
};
const keyArrayPrefix = 'programs[]';
const keyConditionPrefix = 'model.programs[arrayIndices[arrayIndices.length-1]]';
// Values that are common to all programs
let programForm = [{
key: `${keyArrayPrefix}.name`,
title: 'HomeKit Name',
placeholder: `e.g. My ${appliance.type} Program`
}, {
// (a valid array-element key is required for some reason)
key: `${keyArrayPrefix}.key`,
type: 'flex',
'flex-flow': 'row',
notitle: true,
items: [{
key: `${keyArrayPrefix}.selectonly`,
title: 'Action',
type: 'select',
titleMap: {
false: 'Start program',
true: 'Select program'
}
}, {
key: `${keyArrayPrefix}.key`,
title: 'Appliance Program'
}]
}];
// Add the superset of all program options to the schema
const optionsSchema = {};
for (const program of appliance.programs) {
for (const option of program.options ?? []) {
optionsSchema[_a = option.key] ?? (optionsSchema[_a] = { type: option.type });
const optionSchema = optionsSchema[option.key];
assertIsDefined(optionSchema);
// Apply restrictions to numeric types
if (optionSchema.type === 'integer' || optionSchema.type === 'number') {
if (option.minimum !== undefined) {
optionSchema.minimum = Math.min(optionSchema.minimum ?? Infinity, option.minimum);
}
if (option.maximum !== undefined) {
optionSchema.maximum = Math.max(optionSchema.maximum ?? -Infinity, option.maximum);
}
if (option.multipleOf) {
const gcd = (x, y) => y ? gcd(y, x % y) : x;
optionSchema.multipleOf = gcd(option.multipleOf, optionSchema.multipleOf);
}
}
// Allowed values for (string) enum types
if (optionSchema.type !== 'array' && optionSchema.type !== 'object' && option.values) {
optionSchema.enum ?? (optionSchema.enum = []);
for (const mapping of option.values) {
if (!optionSchema.enum.includes(mapping.key))
optionSchema.enum.push(mapping.key);
}
}
}
}
// Add per-program options to the form
for (const program of appliance.programs) {
const programCondition = `${keyConditionPrefix}.key == "${program.key}"`;
// Add form items to customise the schema for this program
for (let option of program.options ?? []) {
const schemaKey = `${keyArrayPrefix}.options.['${option.key}']`;
const formOption = {
key: schemaKey,
title: option.name,
condition: {
functionBody: `try { return ${programCondition}; } catch (err) { return false; }`
}
};
// Treat restricted numeric types as enum types
if (option.minimum !== undefined && option.maximum !== undefined && option.multipleOf
&& (option.maximum - option.minimum) / option.multipleOf <= MAX_ENUM_STEPS) {
const suffix = option.suffix ? ` ${option.suffix}` : '';
const mappings = [];
for (let value = option.minimum; value <= option.maximum; value += option.multipleOf) {
mappings.push({ name: `${value}${suffix}`, key: value });
}
option = { values: mappings };
}
// Range limit and units for numeric types
if (option.minimum !== undefined)
formOption.minimum = option.minimum;
if (option.maximum !== undefined)
formOption.maximum = option.maximum;
if (option.multipleOf !== undefined)
formOption.multipleOf = option.multipleOf;
if (option.type === 'integer' || option.type === 'number')
formOption.type = 'number';
if (option.suffix) {
formOption.fieldAddonRight = ` ${option.suffix}`;
}
if (option.minimum !== undefined && option.maximum !== undefined) {
const suffix = option.suffix ? ` ${option.suffix}` : '';
formOption.description = `Supported range: ${option.minimum} to ${option.maximum}${suffix}`;
if (option.multipleOf)
formOption.description += `, in steps of ${option.multipleOf}${suffix}`;
}
// Allowed values for enum types
if (option.values) {
formOption.titleMap = {};
for (const mapping of option.values) {
formOption.titleMap[mapping.key.toString()] = mapping.name;
}
}
// If there is a default then add it as placeholder text
if (option.default !== undefined) {
const defaultValue = option.default.toString();
const value = formOption.titleMap?.[defaultValue] ?? defaultValue;
formOption.placeholder = `e.g. ${value}`;
}
programForm.push(formOption);
}
// Add form items to remove options unsupported by this program
const supported = (program.options ?? []).map(option => option.key);
const unsupported = Object.keys(optionsSchema).filter(key => !supported.includes(key));
if (unsupported.length) {
programForm.push({
key: `${keyArrayPrefix}.options.['${unsupported[0]}']`,
condition: {
functionBody: `try { if (${programCondition}) { let options = ${keyConditionPrefix}.options;${unsupported.map(key => ` delete options["${key}"];`).join('')} } } catch (err) {} return false;`
}
});
}
}
// Hide most of the options if Control scope has not been authorised
if (appliance.hasControl === false) {
assertIsDefined(programForm[0]);
assert(programForm[1]?.type === 'flex');
assertIsDefined(programForm[1].items[1]);
programForm = [programForm[0], programForm[1].items[1]];
}
// Create the top-level schema for appliance programs
const schema = {
// Choice of how to handle programs
addprograms: {
type: 'string',
oneOf: [{
title: 'No individual program switches',
const: 'none'
}, {
title: `A switch to start each ${appliance.name} program`,
const: 'auto'
}, {
title: 'Custom list of programs and options',
const: 'custom'
}],
default: 'auto',
required: true
},
// Array of programs
programs: {
type: 'array',
uniqueItems: true,
items: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
required: true
},
key: {
type: 'string',
minLength: 1,
oneOf: appliance.programs.map(program => ({
title: program.name,
const: program.key
})),
required: true,
default: appliance.programs[0]?.key
},
selectonly: {
type: 'boolean',
required: true,
default: false
},
options: {
type: 'object',
properties: optionsSchema
}
}
}
}
};
const programListCondition = {
functionBody: 'try { return model.addprograms == "custom"; } catch (err) { return true; }'
};
const form = [{
key: 'addprograms',
title: 'Program Switches',
description: 'A separate Switch service can be created for individual appliance programs. These indicate which program is running, and (if authorised) can be used to select options and start a specific program.'
}, {
type: 'help',
helpvalue: '<div class="help-block"><p>Specify a unique HomeKit Name for each program (preferably short and without punctuation).</p><p>The same Appliance Program may be used multiple times with different options.</p></div>',
condition: programListCondition
}, {
key: 'programs',
notitle: true,
startEmpty: true,
items: programForm,
condition: programListCondition
}];
// Delete the programs member or set an empty array if appropriate
// (workaround homebridge-config-ui-x / angular6-json-schema-form)
const code = 'switch (model.addprograms) { case "none": model.programs = []; break; case "auto": delete model.programs; break; }';
return { schema, form, code };
}
// Retrieve the active plugin configuration
async getConfig() {
await this.load(true);
return this.config;
}
// Retrieve the global configuration schema
async getSchemaGlobal() {
await this.load(true);
// Generate schema fragments for non-appliance configuration
const pluginSchema = this.getSchemaFragmentPlugin();
const clientSchema = this.getSchemaFragmentClient();
const debugSchema = this.getSchemaFragmentDebug();
// Combine the schema fragments
const schema = {
type: 'object',
properties: { ...pluginSchema.schema, ...clientSchema.schema, ...debugSchema.schema }
};
const form = [{
type: 'fieldset',
title: 'Homebridge Plugin Name',
expandable: false,
items: pluginSchema.form,
condition: {
functionBody: `return model.name !== "${PLATFORM_NAME}";`
}
}, {
type: 'fieldset',
title: 'Home Connect Client',
expandable: false,
items: clientSchema.form,
condition: {
functionBody: 'try { return !model.debug.includes("Mock Appliances") } catch (err) { return true; }'
}
}, {
type: 'fieldset',
title: 'Debug Options',
expandable: true,
expanded: false,
items: debugSchema.form
}];
// Return the schema
return { schema, form };
}
// Retrieve the configuration schema for a specified appliance
async getSchemaAppliance(haid) {
await this.load(true);
const appliance = this.appliances.get(haid);
if (!appliance)
return;
// Generate schema fragments for the appliance configuration
const namesSchema = this.getSchemaFragmentApplianceNames(appliance);
const featuresSchema = this.getSchemaFragmentApplianceOptionalFeatures(appliance);
const programsSchema = this.getSchemaFragmentAppliancePrograms(appliance);
// Combine the schema fragments
const schema = {
type: 'object',
properties: {
enabled: {
type: 'boolean',
default: true
},
...namesSchema.schema,
...featuresSchema.schema,
...programsSchema.schema
}
};
const form = [{
key: 'enabled',
title: appliance.name,
description: `${appliance.brand} ${appliance.type} (E-Nr: ${appliance.enumber})`
}, {
type: 'help',
helpvalue: 'This appliance will not be exposed to HomeKit.',
condition: {
functionBody: 'return !model.enabled;'
}
}, {
type: 'fieldset',
notitle: true,
items: [...namesSchema.form, ...featuresSchema.form, ...programsSchema.form],
condition: {
functionBody: `try { ${programsSchema.code ?? ''} } catch (err) {} return !!model.enabled;`
}
}];
// Return the schema
return { schema, form };
}
}
//# sourceMappingURL=schema.js.map