homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
630 lines • 27.7 kB
JavaScript
/* eslint-disable max-len */
// Homebridge plugin for Home Connect home appliances
// Copyright © 2019-2026 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',
enum: [true, false],
enumNames: ['Simulated Appliances (test server)', 'Physical Appliances (production server)'],
default: false,
required: true
},
china: {
type: 'boolean',
enum: [true, false],
enumNames: ['China', 'Worldwide (excluding China)'],
default: false,
required: true
},
language: {
type: 'object',
properties: {
api: this.getSchemaHomeConnectLanguages()
}
}
};
const form = [{
key: 'simulator',
title: 'Client Type',
type: 'select'
}, {
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',
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 experimental feature flags
getSchemaFragmentExperimental() {
const schema = {
experimental: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: keyofChecker(typeSuite, typeSuite.ExperimentalFeatures)
}
}
};
const form = [{
key: 'experimental',
notitle: true,
description: 'These <a href="https://github.com/thoukydides/homebridge-homeconnect/wiki/Experimental-Features">experimental features</a> are not fully tested and may cause incorrect behaviour or other problems. Issues that occur only when experimental features are enabled may be closed without investigation. Only enable them if you understand and accept these limitations.'
}];
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
}, {
type: 'help',
helpvalue: `e.g. "<i>${name}</i>"`,
condition: {
functionBody: `return !model.names.prefix.${key};`
}
}, {
type: 'help',
helpvalue: `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'
}, {
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) {
// (workaround homebridge-config-ui-x / @ng-formworks/core)
const escapedProgramKey = program.key.replaceAll('.', '\\u002E');
const programCondition = `${keyConditionPrefix}.key === "${escapedProgramKey}"`;
// 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',
enum: [true, false],
enumNames: ['Select program', 'Start program'],
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
}];
// The programs array is updated by setAppliance() based on addprograms
return { schema, form };
}
// 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 experimentalSchema = this.getSchemaFragmentExperimental();
const debugSchema = this.getSchemaFragmentDebug();
// Combine the schema fragments
const schema = {
type: 'object',
properties: { ...pluginSchema.schema, ...clientSchema.schema, ...experimentalSchema.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: 'Experimental Feature Flags',
expandable: true,
expanded: false,
items: experimentalSchema.form
}, {
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