UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

1,394 lines (1,348 loc) 659 kB
(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