homebridge-homeconnect
Version:
A Homebridge plugin that connects Home Connect appliances to Apple HomeKit
1,394 lines (1,348 loc) • 659 kB
JavaScript
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.APIStatus = void 0;
var _utils = require("../../utils.js");
var _utilsDom = require("./utils-dom.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2024-2025 Alexander Thoukydides
// URL for the server status image
const STATUS_URL = 'https://homeconnect.thouky.co.uk/api/images/plugin-status.svg';
// Warn for errors more recent than this
const STATUS_SINCE = 24 * 60 * 60 * _utils.MS;
// Interval to poll for updates (if there are issues)
const POLL_INTERVAL = 2.5 * 60 * _utils.MS;
// Home Connect API server status
class APIStatus {
// Create a new API server status checker
constructor(log) {
this.log = log;
this.pollStatus();
}
// Check the status of the Home Connect API servers
async pollStatus() {
// Attempt to fetch the status image
const response = await fetch(STATUS_URL);
if (!response.ok) {
this.log.warn('checkStatus fetch', response.statusText);
return;
}
const svg = await response.text();
// Attempt to parse the status header
let status;
try {
status = JSON.parse(response.headers.get('X-Server-Status') ?? '');
(0, _utils.assertIsBoolean)(status.up);
(0, _utils.assertIsNumber)(status.since);
(0, _utils.assertIsNumber)(status.updated);
} catch (err) {
this.log.warn('checkStatus X-Server-Status', err);
return;
}
// Update the displayed status, if it has changed
let pollAgain = true;
if (this.lastUpdated !== status.updated) {
this.lastUpdated = status.updated;
pollAgain = this.showStatus(svg, status);
}
// Periodically poll the status if there are server issues
if (pollAgain) setTimeout(() => this.pollStatus(), POLL_INTERVAL);
}
// Display the latest status of the Home Connect API servers
showStatus(svg, status) {
// Display the appropriate summary
(0, _utilsDom.getElementById)('hc-api-up').hidden = !status.up;
(0, _utilsDom.getElementById)('hc-api-up2').hidden = !status.up;
(0, _utilsDom.getElementById)('hc-api-down').hidden = status.up;
(0, _utilsDom.getElementById)('hc-api-down2').hidden = status.up;
// Add the detailed status
(0, _utilsDom.getElementById)('hc-api-since').innerText = this.formatDateTime(status.since);
// Add the retrieved status image
(0, _utilsDom.getElementById)('hc-api-svg').innerHTML = svg;
// Show or hide the status as appropriate
const showStatus = !status.up || Date.now() < status.since + STATUS_SINCE;
(0, _utilsDom.getElementById)('hc-api-status').hidden = !showStatus;
return showStatus;
}
// Format a date/time
formatDateTime(date) {
return new Date(date).toLocaleString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
});
}
}
exports.APIStatus = APIStatus;
},{"../../utils.js":12,"./utils-dom.js":9}],2:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.Cards = void 0;
var _utils = require("../../utils.js");
var _utilsDom = require("./utils-dom.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Cards to select global or per-appliance configuration
class Cards {
// Construct a new cards handler
constructor(log) {
this.log = log;
// Current cards
this.nonApplianceCards = [];
this.applianceCards = [];
this.parentElement = (0, _utilsDom.getElementById)('hc-appliance-cards');
}
// Update the list of non-appliance cards
setNonAppliances(cards) {
this.nonApplianceCards = cards;
this.updateCards();
}
// Update the list of appliances
setAppliances(appliances) {
// Convert the appliances to card descriptions
const applianceCards = appliances.map(appliance => ({
id: appliance.haId,
icon: appliance.type.toLocaleLowerCase(),
name: appliance.name,
detail: `${appliance.brand} ${appliance.enumber}`
}));
applianceCards.sort((a, b) => a.name.localeCompare(b.name));
// Check whether the list of appliances has changed
if (JSON.stringify(applianceCards) !== JSON.stringify(this.applianceCards)) {
this.applianceCards = applianceCards;
this.updateCards();
}
}
// Update the list of cards and restore the selection, if still valid
updateCards() {
// Replace the existing cards with new ones
const allCards = [...this.nonApplianceCards, ...this.applianceCards];
this.parentElement.replaceChildren(...allCards.map(card => this.makeCard(card)));
this.parentElement.hidden = !allCards.length;
// Restore any previous selection
this.selectCard(this.selectedCardId);
}
// Create a new card
makeCard({
id,
icon,
name,
detail
}) {
// Create a new card from the template
detail ?? (detail = ' '); // (non-breaking space)
const card = (0, _utilsDom.cloneTemplate)('hc-appliance-card', {
name,
detail
});
this.loadCardIcon((0, _utilsDom.getSlot)(card, 'icon'), `./images/icon-${icon}.svg`, id);
// Handle card selection
const element = card.children[0];
(0, _utils.assertIsInstanceOf)(element, HTMLElement);
element.dataset.id = id;
element.onclick = () => {
this.selectCard(id);
};
return card;
}
// Load the icon image for a card
async loadCardIcon(element, url, id) {
try {
// Load the icon and add it to the card
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch icon ${response.url}: ${response.statusText}`);
element.innerHTML = await response.text();
// Change the group (layer) IDs to classes
element.querySelectorAll('g[id]').forEach(group => {
const id = group.getAttribute('id');
(0, _utils.assertIsDefined)(id);
group.removeAttribute('id');
group.classList.add(id);
});
// Assign unique IDs to gradient fills
element.querySelectorAll('defs [id]').forEach(def => {
const oldId = def.getAttribute('id');
const newId = `${id}-${oldId}`;
def.setAttribute('id', newId);
element.querySelectorAll(`[fill='url(#${oldId})']`).forEach(ref => {
ref.setAttribute('fill', `url(#${newId})`);
});
});
} catch (err) {
this.log.error(`loadCardIcon(${id}) =>`, err);
}
}
// A card has been selected
selectCard(id) {
// Select this card and deselect all others
let selectedCard;
for (const card of Array.from(this.parentElement.children)) {
(0, _utils.assertIsInstanceOf)(card, HTMLElement);
if (card.dataset.id === id) selectedCard = card;
card.classList.toggle('hc-selected', card.dataset.id === id);
}
if (!selectedCard) id = undefined;
// Notify the client of selection changes
if (this.selectedCardId !== id) {
this.log.debug(`onSelect(${id}) (was ${this.selectedCardId})`);
this.selectedCardId = id;
this.onSelect?.(id);
}
}
}
exports.Cards = Cards;
},{"../../utils.js":12,"./utils-dom.js":9}],3:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.ClientClientID = void 0;
var _utils = require("../../utils.js");
var _utilsDom = require("./utils-dom.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Minimum time between duplicate toast notifications
const TOAST_DEDUP_TIME = 5 * _utils.MS;
// The current Home Connect client
class ClientClientID {
// Create a new Home Connect client
constructor(log, ipc) {
this.log = log;
this.ipc = ipc;
this.ipc.onEvent('status', status => {
this.onClientStatus(status);
});
}
// The Home Connect client configuration has changed
async setClient(config) {
const clientConfig = this.prepareClientConfig(config);
if (clientConfig) {
// Test the client configuration
this.clientConfig = clientConfig;
try {
const status = await this.ipc.request('/clientid', clientConfig);
this.onClientStatus(status);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.showToast('error', `Unable to attempt authorisation ${message}`);
}
} else {
// Ignore the client configuration until it is valid
this.clientConfig = undefined;
this.showPanel();
this.onAppliances?.();
}
}
// Test whether the client configuration is likely to be valid
prepareClientConfig(config) {
if (config.debug?.includes('Mock Appliances')) {
// Mock appliances; use an empty clientid to match the status
return {
...config,
clientid: ''
};
} else if (config.clientid !== undefined && /^[0-9A-F]{64}$/i.test(config.clientid)) {
// Viable clientid
return config;
}
}
// Handle events from the Home Connect client
onClientStatus(status) {
// Ignore the status if no longer monitoring the client
if (this.clientConfig?.clientid !== status.clientid) return;
// Update the list of appliances
this.onAppliances?.(status.appliances);
// Display an appropriate toast notification
switch (status.authorisation?.state) {
case 'success':
this.showToast('success', 'Successfully authorised');
this.showPanel();
break;
case 'user':
this.showToast('info', 'User authorisation required');
this.showUserAuthorisation(status.authorisation);
break;
case 'fail':
this.showToast('error', 'Authorisation failed');
this.showFail(status.authorisation);
this.onFail?.();
break;
case 'busy':
default:
this.showPanel();
}
}
// Prompt the user to authorise the client
showUserAuthorisation(authorisation) {
// Set the link/code
const link = (0, _utilsDom.getElementById)('hc-client-user-link');
link.setAttribute('href', authorisation.uri);
(0, _utilsDom.setSlotText)(document, {
'hc-client-user-code': authorisation.code
});
// Enable generation of a new authorisation code
(0, _utilsDom.getElementById)('hc-client-user-retry').onclick = () => {
this.retryAuthorisation();
};
// Make the authorisation link visible (before triggering transition)
this.showPanel('hc-client-user');
// Start animating the progress bar for the time until the link expires
const progress = (0, _utilsDom.getElementById)('hc-client-user-progress');
progress.classList.remove('hc-progress-zero');
if (authorisation.expires !== null) {
progress.style.transitionDuration = `${authorisation.expires - Date.now()}ms`;
void progress.offsetHeight; // (force a reflow)
progress.classList.add('hc-progress-zero');
}
}
// Display the result of a failed authorisation
showFail(authorisation) {
// Display the error message
(0, _utilsDom.getElementById)('hc-client-fail-message').textContent = authorisation.message;
// Indicate whether the authorisation can be retried
(0, _utilsDom.getElementById)('hc-client-fail-retryable').hidden = !authorisation.retryable;
(0, _utilsDom.getElementById)('hc-client-fail-retry').hidden = !authorisation.retryable;
(0, _utilsDom.getElementById)('hc-client-fail-retry').onclick = () => {
this.retryAuthorisation();
};
// Add any help that has been provided
if (authorisation.help) {
// Set text blocks
const setText = (id, text) => {
(0, _utilsDom.getElementById)(id).replaceChildren(...text.map(paragraph => (0, _utilsDom.cloneTemplate)('hc-client-fail-paragraph', {
paragraph
})));
};
setText('hc-client-fail-prescript', authorisation.help.prescript);
setText('hc-client-fail-postscript', authorisation.help.postscript);
// Provide additional help for creating or modifying the application
const {
client
} = authorisation.help;
const link = (0, _utilsDom.getElementById)('hc-client-fail-uri');
if (client) {
(0, _utilsDom.getElementById)('hc-client-fail-client-settings').replaceChildren(...Object.entries(client.settings).map(([key, value]) => (0, _utilsDom.cloneTemplate)('hc-client-fail-client-setting', {
key,
value
})));
link.setAttribute('href', client.uri);
link.textContent = client.action === 'create' ? 'Create application' : 'Modify application';
link.hidden = false;
(0, _utilsDom.getElementById)('hc-client-fail-client').hidden = false;
} else {
link.hidden = true;
(0, _utilsDom.getElementById)('hc-client-fail-client').hidden = true;
}
// Make the details visible
(0, _utilsDom.getElementById)('hc-client-fail-detail').hidden = false;
} else {
// Otherwise hide the details
(0, _utilsDom.getElementById)('hc-client-fail-uri').hidden = true;
(0, _utilsDom.getElementById)('hc-client-fail-detail').hidden = true;
}
// Make the failure message visible
this.showPanel('hc-client-fail');
}
// Trigger authorisation retry
async retryAuthorisation() {
try {
await this.ipc.request('/clientid/retry', null);
this.showToast('info', 'Requesting new authorisation code');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.showToast('error', `Unable to retry authorisation: ${message}`);
}
}
// Show the specified element and hide all others
showPanel(idShow) {
for (const id of ['hc-client-fail', 'hc-client-user']) {
(0, _utilsDom.getElementById)(id).hidden = id !== idShow;
}
}
// Display a toast notification
showToast(level, message) {
// Avoid duplicate toast notifications
const key = `${level} - ${message}`;
if (this.lastToast === key) return;
this.lastToast = key;
setTimeout(() => {
if (this.lastToast === key) this.lastToast = undefined;
}, TOAST_DEDUP_TIME);
// Display the toast notification
window.homebridge.toast[level](message, 'Home Connect Client');
}
}
exports.ClientClientID = ClientClientID;
},{"../../utils.js":12,"./utils-dom.js":9}],4:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.ClientIPC = void 0;
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Client-side IPC implementation
class ClientIPC {
// Create a new client IPC instance
constructor(log) {
this.log = log;
}
// Issue a request to the server
async request(path, data) {
try {
this.log.debug(`request("${path}", %O)`, data);
const result = await window.homebridge.request(path, data);
this.log.debug(`request("${path}", %O) =>`, data, result);
return result;
} catch (err) {
this.log.error(`request("${path}", %O) =>`, data, err);
throw err;
}
}
// Add an event listener
onEvent(event, callback) {
window.homebridge.addEventListener(event, async evt => {
try {
const data = evt.data;
if (event !== 'log') this.log.debug(`onEvent("${event}") => callback`, data);
await callback(data);
} catch (err) {
this.log.error(`onEvent("${event}") =>`, err);
}
});
}
}
exports.ClientIPC = ClientIPC;
},{}],5:[function(require,module,exports){
"use strict";
var _logger = require("./logger.js");
var _config = require("./config.js");
var _cards = require("./cards.js");
var _clientClientid = require("./client-clientid.js");
var _forms = require("./forms.js");
var _clientIpc = require("./client-ipc.js");
var _apiStatus = require("./api-status.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// A Homebridge HomeConnect custom UI client
class Client {
// Create a new custom UI client
constructor() {
// Create a local logger and IPC client
this.log = new _logger.ClientLogger();
this.log.debug('homebridge.plugin', window.homebridge.plugin);
this.ipc = new _clientIpc.ClientIPC(this.log);
// Wait for the server before continuing initialisation
this.ipc.onEvent('ready', () => {
this.serverReady();
});
}
// The server is ready so finish initialising the client
serverReady() {
// Start receiving (important) log messages from the server
this.serverLog = new _logger.ServerLogger(this.ipc, "warn" /* LogLevel.WARN */);
// Create all of the required resources
const config = new _config.Config(this.log, this.ipc);
const forms = new _forms.Forms(this.log, this.ipc, config);
const cards = new _cards.Cards(this.log);
const client = new _clientClientid.ClientClientID(this.log, this.ipc);
new _apiStatus.APIStatus(this.log);
// Create cards for the global settings and each available appliance
cards.setNonAppliances([{
id: _forms.FormId.Global,
icon: 'global',
name: 'General Settings'
}]);
client.onAppliances = appliances => {
cards.setAppliances(appliances ?? []);
};
cards.onSelect = id => {
forms.showForm(id);
};
// Attempt to authorise a client when the configuration changes
config.onGlobal = config => {
client.setClient(config);
};
client.onFail = () => {
forms.showForm(_forms.FormId.Global);
};
}
}
// Create a custom UI client instance
new Client();
},{"./api-status.js":1,"./cards.js":2,"./client-clientid.js":3,"./client-ipc.js":4,"./config.js":6,"./forms.js":7,"./logger.js":8}],6:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.Config = void 0;
var _utils = require("../../utils.js");
var _utilsDom = require("./utils-dom.js");
var _configTypes = require("../../ti/config-types.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// The current plugin configuration
class Config {
// Create a new configuration object
constructor(log, ipc) {
this.log = log;
this.ipc = ipc;
// The configuration being edited
this.global = {};
this.appliances = {};
this.readyPromise = this.getConfig();
this.activePromise = this.getActiveConfig();
}
// Retrieve and parse the configuration to be edited
async getConfig() {
// Retrieve the current plugin configuration (if any)
const configArray = await window.homebridge.getPluginConfig();
if (configArray[0] === undefined) {
this.log.warn('No plugin configuration found; creating a new one');
this.savedConfig = {
platform: window.homebridge.plugin.displayName
};
} else {
if (1 < configArray.length) {
this.log.error(`Using the first of ${configArray.length} plugin configurations`, configArray);
window.homebridge.toast.error('Only a single platform instance is supported; using the first', 'Multiple Configuration Blocks');
}
this.savedConfig = configArray[0];
this.checkIfModified(this.savedConfig);
}
// Treat all unexpected properties as appliance configurations
const keyofConfigPlugin = (0, _utils.keyofChecker)(_configTypes.typeSuite, _configTypes.typeSuite.ConfigPlugin);
const select = predicate => Object.fromEntries(Object.entries(this.savedConfig ?? {}).filter(predicate));
this.global = select(([key]) => keyofConfigPlugin.includes(key));
this.appliances = select(([key]) => !keyofConfigPlugin.includes(key));
this.log.debug('getConfig() global %O appliances %O', this.global, this.appliances);
this.onGlobal?.(this.global);
}
// Retrieve the most recently used configuration
async getActiveConfig() {
try {
return await this.ipc.request('/config', null);
} catch {
return {};
}
}
// Update the homebridge-ui-x copy of the configuration
async putConfig(save = false) {
// Construct the full configuration
const config = {
...this.global,
...this.appliances
};
this.log.debug(`putConfig(${save})'}`, config);
// Inform homebridge-ui-x of the new configuration
await window.homebridge.updatePluginConfig([config]);
if (save) {
await window.homebridge.savePluginConfig();
this.savedConfig = config;
this.log.info('Plugin configuration saved');
}
// Check whether the configuration matches the active plugin
this.checkIfModified(config);
}
// Compare two configuration objects
diffObject(to, from, keyPrefix = '') {
const keys = [...Object.keys(to), ...Object.keys(from)].filter((key, index, self) => index === self.indexOf(key) && key !== '_bridge');
const diff = [];
for (const key of keys) {
const valueTo = to[key];
const valueFrom = from[key];
const keyName = `${keyPrefix}.${key}`;
const isObject = value => value !== undefined && typeof value === 'object';
if (Array.isArray(valueTo) && Array.isArray(valueFrom)) diff.push(...this.diffArray(valueTo, valueFrom, keyName));else if (isObject(valueTo) && isObject(valueFrom)) diff.push(...this.diffObject(valueTo, valueFrom, keyName));else if (valueTo !== valueFrom) diff.push({
key: keyName,
from: valueFrom,
to: valueTo
});
}
return diff;
}
// Compare two configuration arrays
diffArray(to, from, keyPrefix = '') {
const isSimple = array => array.length !== 0 && typeof array[0] !== 'object';
if (isSimple(to) || isSimple(from)) {
if (to.length === from.length && to.every((value, index) => value === from[index])) return [];else return [{
key: keyPrefix,
from,
to
}];
} else {
return this.diffObject(to, from, keyPrefix);
}
}
// Check whether the configuration has been modified
async checkIfModified(config) {
// Ignore completely new/empty configuration
if (!Object.keys(config).length) return;
// Compare the configuration to the active plugin and saved versions
const activeConfig = await this.activePromise;
const activeDiff = this.diffObject(config, activeConfig);
const savedDiff = this.diffObject(config, this.savedConfig ?? {});
if (activeDiff.length || savedDiff.length) {
this.log.debug('checkIfModified(%O) active %O saved %O', config, activeDiff, savedDiff);
}
// Hide or show the restart required message
this.showRestartRequired(activeDiff, 0 < savedDiff.length);
}
// Show or hide the restart required message
showRestartRequired(changes, isUnsaved) {
// Show the message if there are any differences
(0, _utilsDom.getElementById)('hc-modified').hidden = changes.length === 0 && !isUnsaved;
(0, _utilsDom.getElementById)('hc-modified-saved').hidden = isUnsaved;
(0, _utilsDom.getElementById)('hc-modified-unsaved').hidden = !isUnsaved;
// Display the diff
const formatValue = value => JSON.stringify(value, null, 4);
(0, _utilsDom.getElementById)('hc-modified-diff').replaceChildren(...changes.map(change => (0, _utilsDom.cloneTemplate)('hc-modified-diff-delta', {
key: change.key,
from: formatValue(change.from),
to: formatValue(change.to)
})));
}
// Get the current global configuration
async getGlobal() {
await this.readyPromise;
return this.global;
}
// Set updated global configuration
async setGlobal(config) {
if (this.diffObject(config, await this.getGlobal()).length) {
this.log.debug('setGlobal(%O)', config);
this.global = config;
this.onGlobal?.(config);
await this.putConfig();
}
}
// Get the current configuration for a specific appliance
async getAppliance(haid) {
await this.readyPromise;
return this.appliances[haid] ?? {};
}
// Set updated global configuration
async setAppliance(haid, config) {
if (this.diffObject(config, await this.getAppliance(haid)).length) {
this.log.debug(`setAppliance("${haid}", %O)`, config);
this.appliances[haid] = config;
await this.putConfig();
}
}
}
exports.Config = Config;
},{"../../ti/config-types.js":11,"../../utils.js":12,"./utils-dom.js":9}],7:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.TemplateForm = exports.GlobalForm = exports.Forms = exports.FormId = exports.Form = exports.ApplianceForm = void 0;
var _utilsDom = require("./utils-dom.js");
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// A configuration form handler
class Form {
// Create a configuration form
constructor(log) {
this.log = log;
}
// Retrieve and display the form
async createForm() {
// Retrieve the form schema and data
const schema = await this.getSchema();
const data = await this.getData();
// Create the form (adding header and footer)
const patchedSchema = this.patchSchema(schema);
this.log.debug('createForm', patchedSchema, data);
this.form = window.homebridge.createForm(patchedSchema, data);
// Return any changes to the configuration
this.form.onChange(config => this.putData(config));
}
// Hide the form (implicitly called if a different form is created)
destroyForm() {
this.form?.end();
this.form = undefined;
}
// Add a header and footer to the schema form
patchSchema(schema) {
const form = [...(schema.form ?? []), ...this.formFromTemplate('hc-form-footer')];
return {
...schema,
form
};
}
// Create a form item from a template
formFromTemplate(...args) {
const template = (0, _utilsDom.cloneTemplate)(...args);
const absTemplate = (0, _utilsDom.elementWithAbsolutePaths)(template);
return this.formFromHTML(absTemplate);
}
// Create a form item from an HTML element
formFromHTML(element) {
return [{
type: 'help',
helpvalue: (0, _utilsDom.getHTML)(element)
}];
}
}
// A global configuration form handler
exports.Form = Form;
class GlobalForm extends Form {
constructor(log, ipc, config) {
super(log);
this.ipc = ipc;
this.config = config;
}
getSchema() {
return this.ipc.request('/schema/global', null);
}
getData() {
return this.config.getGlobal();
}
putData(data) {
return this.config.setGlobal(data);
}
}
// An configuration form handler for a specific appliance
exports.GlobalForm = GlobalForm;
class ApplianceForm extends Form {
constructor(log, ipc, config, haid) {
super(log);
this.ipc = ipc;
this.config = config;
this.haid = haid;
}
getSchema() {
return this.ipc.request('/schema/appliance', this.haid);
}
getData() {
return this.config.getAppliance(this.haid);
}
putData(data) {
return this.config.setAppliance(this.haid, data);
}
}
// A placeholder form handler
exports.ApplianceForm = ApplianceForm;
class TemplateForm extends Form {
constructor(log, ...args) {
super(log);
this.elementAsForm = this.formFromTemplate(...args);
}
// Create a placeholder schema using the specified HTML element
async getSchema() {
return Promise.resolve({
schema: {
type: 'object',
properties: {}
},
form: this.elementAsForm
});
}
async getData() {
return Promise.resolve({});
}
async putData() {}
}
// Identifiers for special forms
exports.TemplateForm = TemplateForm;
var FormId;
(function (FormId) {
FormId["Global"] = "global";
FormId["Placeholder"] = "placeholder";
FormId["Unavailable"] = "unavailable";
})(FormId || (exports.FormId = FormId = {}));
// Type guard to check whether a string is a special form identifier
function isFormId(value) {
return Object.values(FormId).includes(value);
}
// Manage the configuration forms
class Forms {
// Create a new form manager
constructor(log, ipc, config) {
this.log = log;
this.ipc = ipc;
this.config = config;
this.showForm();
}
// Display the specified form (defaulting to the placeholder)
async showForm(formId) {
try {
// The forms are retrieved from the server, so display a spinner
window.homebridge.showSpinner();
// Display the requested form, with fallback to the error form
try {
await this.constructAndShowForm(formId ?? FormId.Placeholder);
} catch (err) {
this.log.warn('Failed to display form', formId, err);
await this.constructAndShowForm(FormId.Unavailable);
}
} finally {
// Ensure that the spinner is always hidden
window.homebridge.hideSpinner();
}
}
// Try to create the requested form, with fallback to the placeholder
async constructAndShowForm(formId) {
const form = this.constructForm(formId);
await form.createForm();
this.currentForm = form;
}
// Create a specific form
constructForm(formId) {
if (isFormId(formId)) {
switch (formId) {
case FormId.Placeholder:
return new TemplateForm(this.log, 'hc-form-placeholder');
case FormId.Unavailable:
return new TemplateForm(this.log, 'hc-form-unavailable');
case FormId.Global:
return new GlobalForm(this.log, this.ipc, this.config);
}
} else {
return new ApplianceForm(this.log, this.ipc, this.config, formId);
}
}
}
exports.Forms = Forms;
},{"./utils-dom.js":9}],8:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.ServerLogger = exports.ConsoleLogger = exports.ClientLogger = void 0;
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Prefix for client and server log messages
const PLUGIN_PREFIX = window.homebridge.plugin.displayName ?? window.homebridge.plugin.name;
// A logger that sends messages to the debugging console
class ConsoleLogger {
// Create a new console logger
constructor(prefix = '') {
this.prefix = prefix;
}
// Simple wrappers to log a message
error(message, ...params) {
this.log("error" /* LogLevel.ERROR */, message, ...params);
}
warn(message, ...params) {
this.log("warn" /* LogLevel.WARN */, message, ...params);
}
success(message, ...params) {
this.log("success" /* LogLevel.SUCCESS */, message, ...params);
}
info(message, ...params) {
this.log("info" /* LogLevel.INFO */, message, ...params);
}
debug(message, ...params) {
this.log("debug" /* LogLevel.DEBUG */, message, ...params);
}
// Log a message at the specified level
log(level, message, ...params) {
// Add the prefix to the message
if (this.prefix.length) message = `[${this.prefix}] ${message}`;
// Log the message
switch (level) {
case "error" /* LogLevel.ERROR */:
console.error(message, ...params);
break;
case "warn" /* LogLevel.WARN */:
console.warn(message, ...params);
break;
case "success" /* LogLevel.SUCCESS */:
console.info(message, ...params);
break;
case "info" /* LogLevel.INFO */:
console.info(message, ...params);
break;
case "debug" /* LogLevel.DEBUG */:
console.debug(message, ...params);
break;
}
}
}
// Logger for locally generated messages
exports.ConsoleLogger = ConsoleLogger;
class ClientLogger extends ConsoleLogger {
// Create a new client logger
constructor() {
super(`${PLUGIN_PREFIX} client`);
}
}
// Logger that receives messages events from the server
exports.ClientLogger = ClientLogger;
class ServerLogger extends ConsoleLogger {
// Create a new server logger
constructor(ipc, minLevel) {
super(`${PLUGIN_PREFIX} server`);
this.ipc = ipc;
// Start receiving log events
ipc.onEvent('log', messages => {
this.logMessages(messages);
});
try {
ipc.request('/log', minLevel);
} catch {/* empty */}
}
// Log messages received from the server
logMessages(messages) {
for (const {
level,
message,
params
} of messages) this.log(level, message, ...params);
}
}
exports.ServerLogger = ServerLogger;
},{}],9:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.cloneTemplate = cloneTemplate;
exports.elementWithAbsolutePaths = elementWithAbsolutePaths;
exports.getElementById = getElementById;
exports.getHTML = getHTML;
exports.getSlot = getSlot;
exports.setSlotText = setSlotText;
var _assert = _interopRequireDefault(require("assert"));
var _utils = require("../../utils.js");
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Get an HTML element by its "id" attribute
function getElementById(elementId) {
const element = document.getElementById(elementId);
(0, _utils.assertIsInstanceOf)(element, HTMLElement);
return element;
}
// Get an HTML element using a slot name
function getSlot(within, slotName) {
const slots = within.querySelectorAll(`[slot="${slotName}"]`);
_assert.default.equal(slots.length, 1, `Expected exactly one slot with name "${slotName}"`);
(0, _utils.assertIsInstanceOf)(slots[0], HTMLElement);
return slots[0];
}
// Set the text content of multiple slot elements
function setSlotText(within, slotText) {
for (const [slotName, text] of Object.entries(slotText)) getSlot(within, slotName).textContent = text;
}
// Create a copy of a template element
function cloneTemplate(elementId, slotText) {
// Find the template element
const template = document.getElementById(elementId);
(0, _utils.assertIsInstanceOf)(template, HTMLTemplateElement);
// Clone the template's document-fragment and set slot values
const documentFragment = template.content.cloneNode(true);
(0, _utils.assertIsInstanceOf)(documentFragment, DocumentFragment);
if (slotText) setSlotText(documentFragment, slotText);
return documentFragment;
}
// Make URI paths absolute
function elementWithAbsolutePaths(fragment) {
for (const attribute of ['href', 'src']) {
const elements = fragment.querySelectorAll(`[${attribute}]`);
for (const element of Array.from(elements)) {
const path = element.getAttribute(attribute);
(0, _utils.assertIsDefined)(path);
element.setAttribute(attribute, new URL(path, location.href).href);
}
}
return fragment;
}
// Obtain the HTML serialization of a template element
function getHTML(fragment) {
const serializer = new XMLSerializer();
return serializer.serializeToString(fragment);
}
},{"../../utils.js":12,"assert":13}],10:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = exports.DebugFeatures = exports.ConfigPlugin = exports.ConfigAppliances = exports.ApplianceProgramOptions = exports.ApplianceProgramConfig = exports.ApplianceNamesPrefix = exports.ApplianceFeatures = exports.ApplianceConfig = exports.AddProgramsConfig = void 0;
var t = _interopRequireWildcard(require("ts-interface-checker"));
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
/**
* This module was automatically generated by `ts-interface-builder`
*/
// tslint:disable:object-literal-key-quotes
const ConfigPlugin = exports.ConfigPlugin = t.iface([], {
"platform": "string",
"name": t.opt("string"),
"clientid": "string",
"clientsecret": t.opt("string"),
"simulator": t.opt("boolean"),
"china": t.opt("boolean"),
"language": t.iface([], {
"api": "string"
}),
"debug": t.opt(t.array("DebugFeatures"))
});
const DebugFeatures = exports.DebugFeatures = t.union(t.lit('Log API Headers'), t.lit('Log API Bodies'), t.lit('Log Appliance IDs'), t.lit('Log Debug as Info'), t.lit('Mock Appliances'));
const ConfigAppliances = exports.ConfigAppliances = t.iface([], {
[t.indexKey]: "ApplianceConfig"
});
const AddProgramsConfig = exports.AddProgramsConfig = t.union(t.lit('none'), t.lit('auto'), t.lit('custom'));
const ApplianceConfig = exports.ApplianceConfig = t.iface([], {
"enabled": t.opt("boolean"),
"names": t.opt(t.iface([], {
"prefix": "ApplianceNamesPrefix"
})),
"features": t.opt("ApplianceFeatures"),
"addprograms": t.opt("AddProgramsConfig"),
"programs": t.opt(t.array("ApplianceProgramConfig"))
});
const ApplianceNamesPrefix = exports.ApplianceNamesPrefix = t.iface([], {
"programs": t.opt("boolean"),
"other": t.opt("boolean")
});
const ApplianceFeatures = exports.ApplianceFeatures = t.iface([], {
[t.indexKey]: "boolean"
});
const ApplianceProgramConfig = exports.ApplianceProgramConfig = t.iface([], {
"name": "string",
"key": "string",
"selectonly": t.opt("boolean"),
"options": t.opt("ApplianceProgramOptions")
});
const ApplianceProgramOptions = exports.ApplianceProgramOptions = t.iface([], {
[t.indexKey]: t.union("string", "number", "boolean")
});
const exportedTypeSuite = {
ConfigPlugin,
DebugFeatures,
ConfigAppliances,
AddProgramsConfig,
ApplianceConfig,
ApplianceNamesPrefix,
ApplianceFeatures,
ApplianceProgramConfig,
ApplianceProgramOptions
};
var _default = exports.default = exportedTypeSuite;
},{"ts-interface-checker":61}],11:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.typeSuite = exports.default = exports.checkers = void 0;
var _tsInterfaceChecker = require("ts-interface-checker");
var _configTypesTi = _interopRequireDefault(require("./config-types-ti.js"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
// This module was automatically generated by node ts-interface-post.ts
// 2025-08-12T13:11:56.820Z
// Type definitions
const typeSuite = exports.typeSuite = _configTypesTi.default;
// Checkers
const checkers = exports.checkers = (0, _tsInterfaceChecker.createCheckers)(_configTypesTi.default);
// Export the checkers by default
var _default = exports.default = checkers;
},{"./config-types-ti.js":10,"ts-interface-checker":61}],12:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.MS = void 0;
exports.assertIsBoolean = assertIsBoolean;
exports.assertIsDefined = assertIsDefined;
exports.assertIsInstanceOf = assertIsInstanceOf;
exports.assertIsNumber = assertIsNumber;
exports.assertIsString = assertIsString;
exports.assertIsUndefined = assertIsUndefined;
exports.columns = columns;
exports.deepMerge = deepMerge;
exports.formatList = formatList;
exports.formatMilliseconds = formatMilliseconds;
exports.formatSeconds = formatSeconds;
exports.getValidationTree = getValidationTree;
exports.keyofChecker = keyofChecker;
exports.plural = plural;
var _assert = _interopRequireDefault(require("assert"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
// Homebridge plugin for Home Connect home appliances
// Copyright © 2023-2025 Alexander Thoukydides
// Milliseconds in a second
const MS = exports.MS = 1000;
// Type assertions
function assertIsDefined(value) {
_assert.default.notStrictEqual(value, undefined);
_assert.default.notStrictEqual(value, null);
}
function assertIsUndefined(value) {
_assert.default.strictEqual(value, undefined);
}
function assertIsString(value) {
_assert.default.strictEqual(typeof value, 'string');
}
function assertIsNumber(value) {
_assert.default.strictEqual(typeof value, 'number');
}
function assertIsBoolean(value) {
_assert.default.strictEqual(typeof value, 'boolean');
}
function assertIsInstanceOf(value, type) {
(0, _assert.default)(value instanceof type, `Not an instance of ${type.name}`);
}
// Format a milliseconds duration
function formatMilliseconds(ms, maxParts = 2) {
if (ms < 1) return 'n/a';
// Split the duration into components
const duration = [['day', Math.floor(ms / (24 * 60 * 60 * MS))], ['hour', Math.floor(ms / (60 * 60 * MS)) % 24], ['minute', Math.floor(ms / (60 * MS)) % 60], ['second', Math.floor(ms / MS) % 60], ['millisecond', Math.floor(ms) % MS]];
// Remove any leading zero components
while (duration[0]?.[1] === 0) duration.shift();
// Combine the required number of remaining components
return duration.slice(0, maxParts).filter(([_key, value]) => value !== 0).map(([key, value]) => plural(value, key)).join(' ');
}
// Format a seconds duration
function formatSeconds(seconds, maxParts = 2) {
return formatMilliseconds(seconds * 1000, maxParts);
}
// Format a list (with Oxford comma)
function formatList(items) {
switch (items.length) {
case 0:
return 'n/a';
case 1:
return items[0] ?? '';
case 2:
return `${items[0]} and ${items[1]}`;
default:
return [...items.slice(0, -1), `and ${items[items.length - 1]}`].join(', ');
}
}
// Format a counted noun (handling most regular cases automatically)
function plural(count, noun, showCount = true) {
const [singular, plural] = Array.isArray(noun) ? noun : [noun, ''];
noun = count === 1 ? singular : plural;
if (!noun) {
// Apply regular rules
const rules = [['on$', 'a', 2],
// phenomenon/phenomena criterion/criteria
['us$', 'i', 1],
// cactus/cacti focus/foci
['[^aeiou]y$', 'ies', 1],
// cty/cites puppy/puppies
['(ch|is|o|s|sh|x|z)$', 'es', 0],
// iris/irises truss/trusses
['', 's', 0] // cat/cats house/houses
];
const rule = rules.find(([ending]) => new RegExp(ending, 'i').test(singular));
assertIsDefined(rule);
const matchCase = s => singular === singular.toUpperCase() ? s.toUpperCase() : s;
noun = singular.substring(0, singular.length - rule[2]).concat(matchCase(rule[1]));
}
return showCount ? `${count} ${noun}` : noun;
}
// Format strings in columns
function columns(rows, separator = ' ') {
// Determine the required column widths
const width = [];
rows.forEach(row => {
row.forEach((value, index) => {
width[index] = Math.max(width[index] ?? 0, value.length);
});
});
width.splice(-1, 1, 0);
// Format the rows
return rows.map(row => row.map((value, index) => value.padEnd(width[index] ?? 0)).join(separator));
}
// Recursive object assignment, skipping undefined values
function deepMerge(...objects) {
const isObject = value => value !== undefined && typeof value === 'object' && !Array.isArray(value);
return objects.reduce((acc, object) => {
Object.entries(object).forEach(([key, value]) => {
const accValue = acc[key];
if (value === undefined) return;
if (isObject(accValue) && isObject(value)) acc[key] = deepMerge(accValue, value);else acc[key] = value;
});
return acc;
}, {});
}
// Convert checker validation error into lines of text
function getValidationTree(errors) {
const lines = [];
errors.forEach((error, index) => {
const prefix = (a, b) => index < errors.length - 1 ? a : b;
lines.push(`${prefix('├─ ', '└─ ')}${error.path} ${error.message}`);
if (error.nested) {
const nested = getValidationTree(error.nested);
lines.push(...nested.map(line => `${prefix('│ ', ' ')} ${line}`));
}
});
return lines;
}
// Extract property keys or union literal from a ti-interface-checker type
function keyofChecker(typeSuite, type) {
const checker = type;
// TIface
const props = [];
if (checker.propSet instanceof Set) {
props.push(...checker.propSet);
}
if (Array.isArray(checker.bases)) {
for (const base of checker.bases) {
const baseType = typeSuite[base];
assertIsDefined(baseType);
props.push(...keyofChecker(typeSuite, baseType));
}
}
// TUnion or TIntersection
if (Array.isArray(checker.ttypes)) {
for (const ttype of checker.ttypes) props.push(...keyofChecker(typeSuite, ttype));
}
// TEnum
if (checker.validValues instanceof Set) {
props.push(...checker.validValues);
}
if (typeof checker.value === 'string') {
// TLiteral
props.push(checker.value);
} else if (typeof checker.name === 'string') {
// TName
const nameType = typeSuite[checker.name];
assertIsDefined(nameType);
props.push(...keyofChecker(typeSuite, nameType));
}
return props;
}
},{"assert":13}],13:[function(require,module,exports){
(function (global){(function (){
'use strict';
var objectAssign = require('object.assign/polyfill')();
// compare and isBuffer taken from https://github.com/feross/buffer/blob/680e9e5e488f22aac27599a57dc844a6315928dd/index.js
// original notice:
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <feross@feross.org> <http://feross.org>
* @license MIT
*/
function compare(a, b) {
if (a === b) {
return 0;
}
var x = a.length;
var y = b.length;
for (var i = 0, len = Math.min(x, y); i < len; ++i) {
if (a[i] !== b[i]) {
x = a[i];
y = b[i];
break;
}
}
if (x < y) {
return -1;
}
if (y < x) {
return 1;
}
return 0;
}
function isBuffer(b) {
if (global.Buffer && typeof global.Buffer.isBuffer === 'function') {
return global.Buffer.isBuffer(b);
}
return !!(b != null && b._isBuffer);
}
// based on node assert, original notice:
// NB: The URL to the CommonJS spec is kept just for tradition.
// node-assert has evolved a lot since then, both in API and behavior.
// http://wiki.commonjs.org/wiki/Unit_Testing/1.0
//
// THIS IS NOT TESTED NOR LIKELY TO WORK OUTSIDE V8!
//
// Originally from narwhal.js (http://narwhaljs.org)
// Copyright (c) 2009 Thomas Robinson <280north.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the 'Software'), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
var util = require('util/');
var hasOwn = Object.prototype.hasOwnProperty;
var pSlice = Array.prototype.slice;
var functionsHaveNames = (function () {
return function foo() {}.name === 'foo';
}());
function pToString (obj) {
return Object.prototype.toString.call(obj);
}
function isView(arrbuf) {
if (isBuffer(arrbuf)) {
return false;
}
if (typeof global.ArrayBuffer !== 'function') {
return false;
}
if (typeof ArrayBuffer.isView === 'function') {
return ArrayBuffer.isView(arrbuf);
}
if (!arrbuf) {
return false;
}
if (arrbuf instanceof DataView) {
return true;
}
if (arrbuf.buffer && arrbuf.buffer instanceof ArrayBuffer) {
return true;
}
return false;
}
// 1. The assert module provides functions that throw
// AssertionError's when particular conditions are not met. The
// assert module must conform to the following interface.
var assert = module.exports = ok;
// 2. The AssertionError is defined in assert.
// new assert.AssertionError({ message: message,
// actual: actual,
// expected: expected })
var regex = /\s*function\s+([^\(\s]*)\s*/;
// based on https://github.com/ljharb/function.prototype.name/blob/adeeeec8bfcc6068b187d7d9fb3d5bb1d3a30899/implementation.js
function getName(func) {
if (!util.isFunction(func)) {
return;
}
if (functionsHaveNames) {
return func.name;
}
var str = func.toString();
var match = str.match(regex);
return match && match[1];
}
assert.AssertionError = function AssertionError(options) {
this.name = 'AssertionError';
this.actual = options.actual;
this.expected = options.expected;
this.operator = options.operator;
if (options.message) {
this.message = options.message;
this.generatedMessage = false;
} else {
this.message = getMessage(this);
this.generatedMessage = true;
}
var stackStartFunction = options.stackStartFunction || fail;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, stackStartFunction);
} else {
// non v8 browsers so we