irrigationsystem
Version:
HomeKit irrigation system using HAP-NodeJS library
1,683 lines (1,380 loc) • 63.1 kB
JavaScript
// Module: HomeKitUI (Frontend App)
//
// Client-side application for the HomeKitUI web interface.
// Responsible for rendering the UI, managing navigation state,
// interacting with backend API endpoints, handling authentication,
// and coordinating live runtime updates.
//
// Responsibilities:
// - Manage shared frontend application state
// - Handle page navigation and browser history integration
// - Fetch data from HomeKitUI backend API endpoints
// - Render built-in pages (status, logs, HomeKit, config)
// - Render project-specific dashboard/configuration pages
// - Handle configuration editing and save workflows
// - Stream logs via Server-Sent Events (SSE)
// - Manage optional bearer-token authentication
// - Provide runtime error handling and user feedback
//
// Features:
// - URL hash-based navigation (e.g. /#dashboard)
// - Browser refresh persistence of selected page
// - Back/forward browser navigation support
// - Dynamic page loading and caching
// - Live log streaming with automatic reconnect
// - Schema-driven configuration rendering
// - Persistent collapse/visible UI state
// - Local bearer-token persistence for authenticated backends
// - Trusted backend-rendered HTML dashboard support
//
// Architecture:
// - Designed specifically for the HomeKitUI backend module
// - No external frontend framework (vanilla JS only)
// - UI rendering performed via direct DOM updates
// - Backend communication flows through a shared authenticated API wrapper
// - Runtime updates handled via polling and SSE streams
// - Project pages remain backend-driven and declarative
//
// Authentication:
// - Optional bearer-token authentication support
// - Supports session-only or persistent browser authentication
// - API requests use Authorization: Bearer headers
// - SSE log streaming falls back to query-token authentication because
// native browser EventSource does not support custom headers
// - Authentication state can fully lock the UI until valid credentials
// are provided
//
// Notes:
// - Authentication remains fully optional and backend-controlled
// - UI remains functional without authentication when disabled server-side
// - Project-specific pages may provide trusted HTML/CSS when enabled by backend
// - All frontend actions route through centralized event delegation
//
// Code version 2026.05.07
// Mark Hulskamp
/* global EventSource, alert, confirm, document, fetch, window, DOMParser */
'use strict';
/* constants */
const AUTH_REQUIRED_MESSAGE = 'Authentication required';
// Runtime UI state shared across all render functions
let state = {
page: window.location.hash.replace('#', '') || 'status',
info: {},
homekit: {},
config: {},
schema: {},
uiSchema: {},
pageData: {},
collapse: {},
visible: {},
logs: [],
error: undefined,
authRequired: false,
changedPaths: new Set(),
};
// Core UI always includes the status page only
let appName = document.title || 'HomeKitUI';
let corePages = [{ id: 'status', title: 'Status', icon: 'home' }];
let logReconnectTimer = undefined;
let logStream = undefined;
let logsPaused = false;
let logsAutoScroll = true;
let uptimeSeconds = 0;
let runtimeTimer = undefined;
let lastStatusPoll = 0;
let lastPageRefresh = 0;
let logScrollTop = 0;
let renderTimer = undefined;
let renderPending = false;
let sessionAuthToken = '';
let pendingAuthRequest = undefined;
// Retrieve the active HomeKitUI bearer token.
// Tokens can exist in two places:
//
// - sessionAuthToken
// Temporary in-memory token for the current browser tab/session only.
// Used when the user does NOT select "Remember for this browser".
//
// - localStorage
// Persistent browser storage used when the user enables
// "Remember for this browser". This survives page reloads and browser restarts.
//
// Session token always takes priority over persistent storage.
function authToken() {
return sessionAuthToken || window.localStorage.getItem('homekitui-token') || '';
}
// Merge bearer-token authentication into an existing headers object.
// This keeps API calls compatible with additional headers such as
// Content-Type used by config save and action requests.
//
// If no token exists, return the original headers unchanged so
// authentication remains fully optional when disabled server-side.
function authHeaders(headers = {}) {
let token = authToken();
if (token === '') {
return headers;
}
return {
...headers,
Authorization: 'Bearer ' + token,
};
}
// Check whether an error represents the frontend authentication-required state.
// This lets callers distinguish an expected auth cancellation/lockout from
// normal backend, network, or rendering errors.
function isAuthRequiredError(error) {
return error?.message === AUTH_REQUIRED_MESSAGE;
}
// Request HomeKitUI bearer-token authentication using a styled modal.
// This replaces the native browser prompt so authentication matches the
// rest of the HomeKitUI interface and theme styling.
//
// Returns:
// {
// token: string,
// remember: boolean
// }
//
// or undefined if cancelled by the user.
function requestAuthToken() {
return new Promise((resolve) => {
// Create a fullscreen modal overlay which blocks interaction with
// the rest of the UI until authentication is completed or cancelled.
let overlay = document.createElement('div');
overlay.className = 'auth-overlay';
// Render the authentication dialog using the same visual style
// as the rest of HomeKitUI.
overlay.innerHTML = `
<form class="auth-card" data-auth-form>
<div class="auth-icon">${lockIcon()}</div>
<div class="auth-title">${escapeHTML(appName)}</div>
<div class="auth-subtitle">
Please enter the Web UI password from your configuration file to continue.
</div>
<label class="auth-field">
<span>Web UI password</span>
<div class="auth-input-wrap">
<input
class="auth-input"
type="password"
autocomplete="current-password"
placeholder="Enter Web UI password"
>
<button
class="auth-eye"
type="button"
title="Show password"
data-auth-show
>
${eyeIcon()}
</button>
</div>
</label>
<label class="auth-remember">
<input type="checkbox" data-auth-remember>
<span>Remember for this browser</span>
<button
class="auth-info"
type="button"
title="The Web UI password is stored locally in this browser only. Recommended only on trusted devices."
data-auth-info
>
i
</button>
</label>
<div class="auth-actions">
<button class="secondary" type="button" data-auth-cancel>Cancel</button>
<button class="primary" type="submit" data-auth-submit>Continue</button>
</div>
</form>
`;
document.body.appendChild(overlay);
// Lookup dialog controls once after rendering.
let input = overlay.querySelector('.auth-input');
let remember = overlay.querySelector('[data-auth-remember]');
let show = overlay.querySelector('[data-auth-show]');
let info = overlay.querySelector('[data-auth-info]');
let form = overlay.querySelector('[data-auth-form]');
let cancel = overlay.querySelector('[data-auth-cancel]');
let closed = false;
// Cleanup helper used by all exit paths.
// Removes the modal and resolves the Promise back to the caller.
let close = (value) => {
if (closed === true) {
return;
}
closed = true;
overlay.remove();
resolve(value);
};
let submitAuth = (event) => {
event?.preventDefault();
event?.stopPropagation();
close({
token: input.value.trim(),
remember: remember.checked === true,
});
};
// User cancelled authentication.
cancel.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
close(undefined);
};
// User submitted a token.
form.onsubmit = submitAuth;
// Toggle password visibility for easier entry/debugging of long tokens.
show.onclick = () => {
input.type = input.type === 'password' ? 'text' : 'password';
show.title = input.type === 'password' ? 'Show password' : 'Hide password';
};
// Display additional information about local token storage behaviour.
info.onclick = () => {
alert(
'The Web UI password is stored locally in this browser only. ' +
'It is only sent to this HomeKitUI instance for authentication. ' +
'Use this option only on trusted/private devices.',
);
};
// Keyboard shortcuts:
// - Enter submits authentication
// - Escape cancels authentication
input.onkeydown = (event) => {
if (event.key === 'Enter') {
submitAuth(event);
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(undefined);
}
};
// Focus the token input automatically for immediate typing.
input.focus();
});
}
// Request and store a bearer token once, even if multiple API calls receive
// authentication failures at the same time.
async function requestStoredAuthToken() {
if (pendingAuthRequest !== undefined) {
return pendingAuthRequest;
}
pendingAuthRequest = requestAuthToken()
.then((auth) => {
if (auth !== undefined && typeof auth.token === 'string' && auth.token.trim() !== '') {
if (auth.remember === true) {
window.localStorage.setItem('homekitui-token', auth.token.trim());
sessionAuthToken = '';
} else {
window.localStorage.removeItem('homekitui-token');
sessionAuthToken = auth.token.trim();
}
}
return auth;
})
.finally(() => {
pendingAuthRequest = undefined;
});
return pendingAuthRequest;
}
function clearStoredAuthToken() {
sessionAuthToken = '';
window.localStorage.removeItem('homekitui-token');
}
function setAuthRequired(required) {
state.authRequired = required === true;
if (state.authRequired === true) {
state.error = undefined;
if (logStream !== undefined) {
try {
logStream.close();
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
logStream = undefined;
}
if (logReconnectTimer !== undefined) {
window.clearTimeout(logReconnectTimer);
logReconnectTimer = undefined;
}
}
}
// Simple API wrapper used by the frontend for all backend requests.
// Handles:
// - automatic bearer-token authentication injection
// - JSON response parsing
// - automatic authentication retry on HTTP 401
// - optional persistent browser token storage
// - consistent error propagation for UI handlers
async function api(apiPath, options = {}) {
// Inject Authorization header when a locally stored token exists.
// Existing request headers (e.g. Content-Type) are preserved.
options.headers = authHeaders(options.headers || {});
let response = await fetch(apiPath, options);
let data = await response.json().catch(() => ({}));
while (response.status === 401) {
let auth = await requestStoredAuthToken();
if (auth === undefined || typeof auth.token !== 'string' || auth.token.trim() === '') {
setAuthRequired(true);
throw new Error(AUTH_REQUIRED_MESSAGE);
}
// Rebuild headers so the retry includes the new token.
options.headers = authHeaders(options.headers || {});
response = await fetch(apiPath, options);
data = await response.json().catch(() => ({}));
if (response.status === 401) {
clearStoredAuthToken();
}
}
setAuthRequired(false);
// Convert backend/API failures into normal JS exceptions so callers
// can handle them consistently with try/catch or alert().
if (response.ok !== true) {
throw new Error(data.error || 'Request failed');
}
return data;
}
// Initial load of UI data from backend
async function load() {
try {
setAuthRequired(false);
state.error = undefined;
state.info = await api('/api/info');
appName = state.info.name || appName;
document.title = appName;
if (Number.isFinite(Number(state.info?.uptime)) === true) {
uptimeSeconds = Number(state.info.uptime);
}
applyTheme(state.info.theme);
state.homekit = await api('/api/homekit');
await loadLogs(false);
} catch (error) {
if (isAuthRequiredError(error) === true) {
render();
return;
}
state.error = String(error.message || error);
}
if (state.page !== 'status') {
try {
await loadPageData(state.page);
if (Object.keys(state.config).length === 0) {
await loadConfig(false);
}
} catch (error) {
if (isAuthRequiredError(error) === true) {
render();
return;
}
state.error = String(error.message || error);
}
}
render();
if (state.authRequired !== true) {
startLogStream();
}
}
// Schedule a single render on the next browser tick.
// This allows multiple state changes in the same event loop to collapse into
// one render() call and avoids timers competing with UI actions.
function scheduleRender() {
renderPending = true;
if (renderTimer !== undefined) {
return;
}
renderTimer = window.setTimeout(() => {
renderTimer = undefined;
if (renderPending !== true) {
return;
}
renderPending = false;
render();
}, 0);
}
// Main render function - builds entire UI shell
function render() {
if (state.authRequired === true) {
document.getElementById('project-style')?.remove();
document.getElementById('app').innerHTML = authRequiredPage();
return;
}
let pages = [...corePages, ...(Array.isArray(state.info.pages) === true ? state.info.pages : [])];
let page = (state.info.pages || []).find((item) => item.id === state.page);
let style = document.getElementById('project-style');
if (style !== null && page?.trustedHTML !== true) {
style.remove();
}
document.getElementById('app').innerHTML = `
<aside>
${pages
.map(
(page) => `
<button
class="${state.page === page.id ? 'active' : ''}"
title="${escapeHTML(page.title)}"
aria-label="${escapeHTML(page.title)}"
data-page="${escapeHTML(page.id)}"
>
${icon(page)}
</button>
`,
)
.join('')}
</aside>
<main>
${state.error !== undefined ? `<div class="error">${escapeHTML(state.error)}</div>` : ''}
${state.page === 'status' ? statusPage() : ''}
${state.page !== 'status' ? projectPage() : ''}
</main>
`;
renderLogsOnly(true);
renderSchemaMount();
restoreCollapseState();
restoreVisibleState();
}
function authRequiredPage() {
return `
<main class="auth-required-page">
<section class="auth-required-card">
<div class="auth-icon">${lockIcon()}</div>
<h1>Authentication required</h1>
<p>Enter the Web UI password to continue.</p>
<button class="primary" data-action="authenticate">Authenticate</button>
</section>
</main>
`;
}
// Render schema-backed form content into the current page after the main
// HTML has been written. The form renderer uses DOM nodes, so it cannot be
// returned directly from the template string used by renderConfigPage().
function renderSchemaMount() {
let mount = document.getElementById('schemaForm');
// No schema form placeholder exists on non-config pages.
if (mount === null) {
return;
}
// Find the active project page so we know which part of the config/schema
// should be rendered into this form.
let page = (state.info.pages || []).find((item) => item.id === state.page);
// Pages without schemaPath are display-only pages and do not have a form.
if (page?.schemaPath === undefined) {
return;
}
// schemaPath is already sanitised by the backend, but validate again before
// resolving nested objects in the frontend.
if (/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*$/.test(page.schemaPath) !== true) {
return;
}
// Pull both the current config value and its matching schema section from
// the configured schema path, then render the generic schema form.
let value = getSchemaPathValue(page.schemaPath);
let schema = getSchemaAtPath(page.schemaPath);
renderSchemaPage(mount, schema, value, page.schemaPath.split('.'));
}
// Generic schema-backed page renderer.
// Dispatches to the correct renderer based on the schema type.
function renderSchemaPage(container, schema, value, path = []) {
if (schema?.type === 'array') {
return renderSchemaArray(container, schema, value, path);
}
if (schema?.type === 'object') {
return renderSchemaObject(container, schema, value, path);
}
return renderSchemaField(container, schema, value, path);
}
// Render an array field from schema.items.
// Object arrays are rendered as config cards, primitive arrays as compact fields.
function renderSchemaArray(container, schema, value = [], path) {
if (schema?.items?.type !== 'object') {
return renderPrimitiveArray(container, schema, value, path);
}
if (Array.isArray(value) === false) {
value = [];
}
let wrapper = document.createElement('div');
wrapper.className = 'config-list';
value.forEach((item, index) => {
let row = document.createElement('div');
row.className = 'card config-card';
let header = document.createElement('div');
header.className = 'config-card-header';
let title = document.createElement('div');
title.className = 'config-card-title';
let displayName = typeof item?.name === 'string' && item.name.trim() !== '' ? item.name : `Item ${index + 1}`;
title.textContent = displayName;
let removeBtn = document.createElement('button');
removeBtn.className = 'secondary';
removeBtn.textContent = 'Remove';
removeBtn.onclick = () => {
value.splice(index, 1);
setValueAtPath(state.config, path, value);
render();
};
header.appendChild(title);
header.appendChild(removeBtn);
row.appendChild(header);
renderSchemaObject(row, schema.items, item, [...path, index]);
wrapper.appendChild(row);
});
container.appendChild(wrapper);
}
// Render an array of primitive values as a single comma-separated field.
// This keeps simple lists such as GPIO pins compact in the generated form.
function renderPrimitiveArray(container, schema, value = [], path) {
if (Array.isArray(value) === false) {
value = value === undefined ? [] : [value];
}
let itemSchema = schema?.items || {};
let label = document.createElement('div');
label.className = 'list-title';
label.textContent = schema.title || path[path.length - 1];
let input = document.createElement('input');
input.type = 'text';
input.value = value.join(', ');
let commit = () => {
let newValue = input.value
.split(',')
.map((item) => item.trim())
.filter((item) => item !== '')
.map((item) => {
if (itemSchema.type === 'number' || itemSchema.type === 'integer') {
let number = Number(item);
if (Number.isFinite(number) === false) {
return undefined;
}
if (Number.isFinite(Number(itemSchema.minimum)) === true && number < Number(itemSchema.minimum)) {
number = Number(itemSchema.minimum);
}
if (Number.isFinite(Number(itemSchema.maximum)) === true && number > Number(itemSchema.maximum)) {
number = Number(itemSchema.maximum);
}
return itemSchema.type === 'integer' ? Math.trunc(number) : number;
}
return item;
})
.filter((item) => item !== undefined);
input.value = newValue.join(', ');
setValueAtPath(state.config, path, newValue);
};
input.onchange = commit;
input.onblur = commit;
container.appendChild(label);
container.appendChild(input);
}
// Render an object field from schema.properties.
// Fields are rendered in schema order as generic form rows.
function renderSchemaObject(container, schema, value = {}, path) {
let props = schema?.properties || {};
Object.keys(props).forEach((key) => {
let fieldSchema = props[key];
let fieldValue = value[key];
let fieldWrapper = document.createElement('div');
fieldWrapper.className = 'config-row';
renderSchemaPage(fieldWrapper, fieldSchema, fieldValue, [...path, key]);
container.appendChild(fieldWrapper);
});
}
// Render a primitive schema field.
// Supports enum/select, boolean/checkbox, number/integer, and string inputs.
function renderSchemaField(container, schema = {}, value, path) {
let label = document.createElement('div');
label.className = 'list-title';
label.textContent = schema.title || path[path.length - 1];
let input;
// Normalise number/integer values against schema constraints
let normaliseNumber = (rawValue) => {
let newValue = rawValue === '' ? undefined : Number(rawValue);
if (newValue !== undefined) {
if (Number.isFinite(Number(schema.minimum)) === true && newValue < Number(schema.minimum)) {
newValue = Number(schema.minimum);
}
if (Number.isFinite(Number(schema.maximum)) === true && newValue > Number(schema.maximum)) {
newValue = Number(schema.maximum);
}
if (schema.type === 'integer') {
newValue = Math.trunc(newValue);
}
}
return newValue;
};
// ENUM (select)
if (Array.isArray(schema.enum) === true) {
input = document.createElement('select');
schema.enum.forEach((option) => {
let opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
if (option === value) {
opt.selected = true;
}
input.appendChild(opt);
});
} else if (schema.type === 'boolean') {
// BOOLEAN
input = document.createElement('input');
input.type = 'checkbox';
input.checked = value === true;
} else if (schema.type === 'number' || schema.type === 'integer') {
// NUMBER / INTEGER
input = document.createElement('input');
input.type = 'number';
input.value = value ?? '';
input.placeholder = 'disabled';
if (Number.isFinite(Number(schema.minimum)) === true) {
input.min = String(schema.minimum);
}
if (Number.isFinite(Number(schema.maximum)) === true) {
input.max = String(schema.maximum);
}
if (schema.type === 'integer') {
input.step = '1';
} else {
input.step = 'any';
}
} else {
// STRING (default)
let isPassword = schema.format === 'password';
let hasExistingPassword = isPassword === true && typeof value === 'string' && value !== '';
input = document.createElement('input');
input.type = isPassword === true ? 'password' : 'text';
input.value = hasExistingPassword === true ? '' : (value ?? '');
input.placeholder = hasExistingPassword === true ? 'Leave unchanged' : '';
input.autocomplete = isPassword === true ? 'new-password' : 'off';
// live update for "name" fields
input.oninput = () => {
if (hasExistingPassword === true && input.value === '') {
setValueAtPath(state.config, path, value);
return;
}
setValueAtPath(state.config, path, input.value);
if (path[path.length - 1] === 'name') {
let card = container.closest('.config-card');
let title = card?.querySelector('.config-card-title');
if (title !== null) {
title.textContent = input.value.trim() !== '' ? input.value : 'Item';
}
}
};
}
// CHANGE HANDLER (final value commit)
// Uses both onchange and onblur to ensure validation runs when leaving the field,
// as some browsers do not fire change for invalid number inputs.
let commit = () => {
let newValue;
if (schema.type === 'boolean') {
newValue = input.checked;
} else if (schema.type === 'number' || schema.type === 'integer') {
newValue = normaliseNumber(input.value);
input.value = newValue ?? '';
} else {
if (schema.format === 'password' && typeof value === 'string' && value !== '' && input.value === '') {
return;
}
newValue = input.value;
}
setValueAtPath(state.config, path, newValue);
};
input.onchange = commit;
input.onblur = commit;
container.appendChild(label);
container.appendChild(input);
}
// Adds a new object entry to a schema-backed config array
function addSchemaItem(schemaPath) {
let path = schemaPath.split('.');
let value = getSchemaPathValue(schemaPath);
let schema = getSchemaAtPath(schemaPath);
if (Array.isArray(value) === false || schema?.items === undefined) {
return;
}
value.push(getDefaultValue(schema.items));
setValueAtPath(state.config, path, value);
render();
}
// Status page combines HomeKit pairing cards, app actions, and logs
function statusPage() {
return `
<div class="page-header">
<div>
<h1>Status</h1>
<div class="page-meta">
App v${escapeHTML(state.info.version || '')} •
UI v${escapeHTML(state.info.uiVersion || '')} •
Port ${escapeHTML(state.info.port || '')} •
Uptime <span class="uptime">${escapeHTML(formatUptime(uptimeSeconds))}</span>
</div>
</div>
<div class="page-actions">
<button title="Restart Service" data-action="restartService">
${restartIcon()}
</button>
<button title="Backup Configuration" data-action="backupConfig">
${downloadIcon()}
</button>
</div>
</div>
<div class="status-layout">
${(state.homekit.accessories || [state.homekit]).map((accessory) => pairingCard(accessory)).join('')}
</div>
${logsCard()}
`;
}
// HomeKit pairing information card
function pairingCard(accessory = state.homekit) {
return `
<section class="pairing-card">
<div class="pairing-title">${escapeHTML(accessory.displayName || state.info.name || 'HomeKit Device')}</div>
<div class="pairing-content">
<div class="pairing-left">
${
accessory.qrCode
? `<img class="qr" src="${accessory.qrCode}" alt="HomeKit QR Code">`
: '<div class="qr-missing">QR unavailable</div>'
}
<div class="pin">${escapeHTML(accessory.pincode || '--- -- ---')}</div>
<div class="pairing-status">
<span class="hap-icon">${homeIcon()}</span>
<span>HAP</span>
<span>•</span>
<button
class="pairing-state ${accessory.paired === true ? 'paired' : 'unpaired'}"
title="${accessory.paired === true ? 'Reset HomeKit Pairing' : 'Not Paired'}"
${
accessory.paired === true
? `data-action="resetPairing" data-username="${escapeHTML(accessory.username || '')}"`
: 'disabled'
}
data-dynamic="pairing"
>
${linkIcon()}
</button>
</div>
<div class="meta">${escapeHTML(accessory.username || '')}</div>
</div>
</div>
</section>
`;
}
// Logs card renders live log output
function logsCard() {
return `
<section class="card logs-card">
<div class="logs-header">
<div class="logs-title">Log</div>
<div class="logs-controls">
<button id="logs-pause" title="Pause logs" data-action="togglePause">
${logsPaused === true ? 'Live' : 'Pause'}
</button>
<button title="Clear logs" data-action="clearLogs">Clear</button>
<button id="logs-scroll" title="Toggle auto-scroll" data-action="toggleScroll">
${logsAutoScroll === true ? 'Scroll' : 'Manual'}
</button>
</div>
</div>
<div id="logs" class="log-output"></div>
</section>
`;
}
// Project-specific page renderer.
// HomeKitUI remains generic by rendering trusted host-provided HTML,
// list data, or schema-backed config sections.
function projectPage() {
// Find the active page definition
let page = (state.info.pages || []).find((item) => item.id === state.page);
if (page === undefined) {
return '';
}
let data = state.pageData[page.id];
// HTML page (fully rendered by trusted backend)
if (page.trustedHTML === true && data !== undefined && data !== null && data.type === 'html' && typeof data.html === 'string') {
// Inject CSS once (or update it if changed)
if (typeof data.css === 'string' && data.css !== '') {
let style = document.getElementById('project-style');
if (style === null) {
style = document.createElement('style');
style.id = 'project-style';
document.head.appendChild(style);
}
if (style.textContent !== data.css) {
style.textContent = data.css;
}
}
return `
<h1>${escapeHTML(page.title)}</h1>
${data.html}
`;
}
// LIST page (inline rendering)
if (data !== undefined && data !== null && data.type === 'list' && Array.isArray(data.items) === true) {
return `
<h1>${escapeHTML(page.title)}</h1>
<section class="card">
<div class="card-title">${escapeHTML(page.title)}</div>
<div class="list">
${data.items
.map((item) => {
// Render each row safely
return `
<div class="list-row">
<div>
<div class="list-title">${escapeHTML(item.title || '')}</div>
<div class="list-sub">${escapeHTML(item.subtitle || '')}</div>
</div>
${item.value !== undefined ? `<div class="list-value">${escapeHTML(String(item.value))}</div>` : ''}
</div>
`;
})
.join('')}
</div>
</section>
`;
}
// Default: schema-driven config page
return renderConfigPage(page);
}
// Generic config page renderer.
// The actual schema-driven form is mounted later by renderSchemaMount().
function renderConfigPage(page) {
let addButton = '';
let hasChanges = state.changedPaths.size > 0;
// Array-backed config pages get an Add button.
if (page?.schemaPath !== undefined) {
let schema = getSchemaAtPath(page.schemaPath);
if (schema?.type === 'array' && schema?.items?.type === 'object') {
addButton = `<button class="secondary" data-action="addSchemaItem" data-path="${escapeHTML(page.schemaPath)}">+ Add</button>`;
}
}
return `
<h1>${escapeHTML(page.title)}</h1>
<section class="card">
<div class="config-page-header">
<div class="card-description">Manage settings</div>
<div class="actions">
<button
id="save-config"
class="${hasChanges === true ? 'primary' : 'secondary'}"
${hasChanges === true ? '' : 'disabled'}
data-action="saveConfig"
>
${hasChanges === true ? 'Save Changes' : 'No Changes'}
</button>
${addButton}
</div>
</div>
<div id="schemaForm"></div>
</section>
`;
}
// Change active page and load data/config if required
async function setPage(page) {
let logs = document.getElementById('logs');
if (logs !== null) {
logScrollTop = logs.scrollTop;
}
state.page = page;
state.error = undefined;
if (window.location.hash !== '#' + page) {
window.location.hash = page;
}
if (page !== 'status') {
try {
await loadPageData(page);
if (Object.keys(state.config).length === 0) {
await loadConfig(false);
}
} catch (error) {
if (isAuthRequiredError(error) === true) {
render();
return;
}
state.error = String(error.message || error);
}
}
lastPageRefresh = 0;
render();
}
// Load dynamic page data from backend
async function loadPageData(pageId) {
try {
state.pageData[pageId] = await api(`/api/page/${pageId}`);
} catch (error) {
if (isAuthRequiredError(error) === true) {
throw error;
}
state.pageData[pageId] = undefined;
}
}
// Load config + schema from backend
async function loadConfig(doRender = true) {
try {
state.config = await api('/api/config');
state.schema = await api('/api/schema');
state.uiSchema = await api('/api/ui-schema');
} catch (error) {
if (isAuthRequiredError(error) === true) {
throw error;
}
state.error = String(error.message || error);
}
if (doRender === true) {
render();
}
}
async function retryAuthentication() {
state.error = undefined;
let auth = await requestStoredAuthToken();
if (auth === undefined || typeof auth.token !== 'string' || auth.token.trim() === '') {
setAuthRequired(true);
render();
return;
}
setAuthRequired(false);
await load();
}
// Save the current in-memory configuration model back to the backend.
// Handles:
// - restart-required detection using schema metadata
// - page-level restart overrides
// - authenticated config persistence
// - clearing tracked frontend change state after successful save
async function saveConfig() {
try {
// Nothing changed, so there is nothing to save.
if (state.changedPaths.size === 0) {
return;
}
// Determine the active page so page-level restart behaviour can override
// schema-level restart detection when explicitly configured.
let page = (state.info.pages || []).find((item) => item.id === state.page);
// Default to "no restart required" unless a changed field explicitly
// requires one via schema metadata or fallback behaviour.
let restartRequired = false;
// Page-level override:
// If restartRequired is explicitly false for this page, never require
// a restart regardless of changed field metadata.
if (page?.restartRequired !== false) {
// Evaluate each changed config path against schema metadata.
//
// Restart detection walks upward through the schema hierarchy:
//
// Example:
// - options.flowRate
// - options
//
// This allows parent schema sections to define restart behaviour
// for entire groups of related configuration fields.
restartRequired = [...state.changedPaths].some((changedPath) => {
let parts = changedPath.split('.');
while (parts.length > 0) {
// Resolve schema at current hierarchy depth.
let schema = getSchemaAtPath(parts.join('.'));
if (schema !== undefined) {
// Explicit override:
// This field/group does NOT require restart.
if (schema.restartRequired === false) {
return false;
}
// Explicit override:
// This field/group DOES require restart.
if (schema.restartRequired === true) {
return true;
}
}
// Move upward one level in schema hierarchy.
parts.pop();
}
// No explicit schema override found.
// Default to safe behaviour and assume restart required.
return true;
});
}
// Persist updated configuration to backend.
// Authentication headers are automatically injected by api().
await api('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.config),
});
// Clear tracked frontend change state after successful save.
state.changedPaths.clear();
updateSaveButton();
// Notify user only when restart is required for changes to apply.
if (restartRequired === true) {
alert('Configuration saved. Restart required for changes to take effect.');
}
} catch (error) {
// Surface backend validation, save, or transport failures directly to user.
alert(String(error.message || error));
}
}
// Download the current backend configuration as a local backup file.
// Uses the authenticated API download helper because normal browser
// links cannot attach Authorization headers for protected endpoints.
async function backupConfig() {
try {
await downloadAPI('/api/backup', 'config.backup.json');
} catch (error) {
// Surface download or authentication failures directly to the user.
alert(String(error.message || error));
}
}
// Send a project-defined UI action to the backend.
// Used by dynamic pages for controls that are not configuration changes,
// such as dashboard buttons, runtime commands, or device actions.
//
// Actions are intentionally generic so HomeKitUI does not need to know
// about project-specific concepts such as irrigation zones, cameras,
// locks, weather systems, or garage doors.
async function sendAction(action, data = {}) {
try {
// Ignore invalid action requests.
if (typeof action !== 'string' || action === '') {
return;
}
// Preserve frontend-only UI state across dynamic page reloads.
// Some project pages use collapsible sections or visible-state
// toggles that should survive backend refreshes after actions.
let collapseState = { ...state.collapse };
let visibleState = { ...state.visible };
// Dispatch action request to backend.
// Authentication headers are automatically injected by api().
await api('/api/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action,
data,
page: state.page,
}),
});
// Refresh the current dynamic page after the action completes so the
// dashboard reflects updated runtime/device state immediately.
//
// Status page is excluded because it already updates independently
// via the shared runtime polling timer.
if (state.page !== 'status') {
await loadPageData(state.page);
}
// Restore preserved frontend-only UI state after page refresh.
state.collapse = collapseState;
state.visible = visibleState;
// Schedule a single re-render after all updates complete.
scheduleRender();
} catch (error) {
// Surface backend or transport failures directly to the user.
alert(String(error.message || error));
}
}
// Start live log stream from HomeKitUI.
// Uses Server-Sent Events so backend log entries can be pushed to the
// browser without polling.
//
// EventSource cannot send custom Authorization headers, so when a bearer
// token is stored locally it is appended as a query parameter. The backend
// only accepts this query-token fallback for the log streaming endpoint.
function startLogStream() {
if (state.authRequired === true) {
return;
}
if (logStream !== undefined) {
return;
}
let token = authToken();
let url = '/api/logs/stream';
if (token !== '') {
url += '?token=' + encodeURIComponent(token);
}
logStream = new EventSource(url);
logStream.onopen = () => {
// When reconnecting after a restart, reload history so startup logs are not missed.
loadLogs(true).catch((error) => {
if (isAuthRequiredError(error) === true) {
render();
}
});
};
logStream.onmessage = (event) => {
try {
let entry = JSON.parse(event.data);
if (entry !== null && typeof entry === 'object') {
state.logs.push(entry);
while (state.logs.length > 500) {
state.logs.shift();
}
appendLog(entry);
}
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Ignore malformed stream entries
}
};
logStream.onerror = () => {
try {
logStream.close();
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
logStream = undefined;
if (logReconnectTimer !== undefined) {
window.clearTimeout(logReconnectTimer);
}
logReconnectTimer = window.setTimeout(() => {
logReconnectTimer = undefined;
startLogStream();
}, 2000);
};
}
// Reload current log history from backend
async function loadLogs(scroll = true) {
try {
state.logs = (await api('/api/logs')).logs || [];
renderLogsOnly(scroll);
} catch (error) {
if (isAuthRequiredError(error) === true) {
throw error;
}
// Ignore transient log reload failures
}
}
// Append a single live log entry without re-rendering the full page
function appendLog(entry) {
if (logsPaused === true) {
return;
}
let logs = document.getElementById('logs');
if (logs === null) {
return;
}
let div = document.createElement('div');
let html = typeof entry.html === 'string' && entry.html.includes('<span') === true ? entry.html : escapeHTML(entry.message || '');
div.className = 'log-line log-' + escapeClassName(entry.level || 'info');
div.innerHTML = html;
logs.appendChild(div);
while (logs.children.length > 500) {
logs.removeChild(logs.firstChild);
}
if (logsAutoScroll === true) {
logs.scrollTop = logs.scrollHeight - logs.clientHeight;
}
}
// Render current log history into the log output element
function renderLogsOnly(scroll = true) {
let logs = document.getElementById('logs');
// Logs element is not present on the current page/render.
if (logs === null) {
return;
}
// Rebuild the current buffered log history as safe HTML.
logs.innerHTML = state.logs
.map((entry) => {
// Ignore invalid log entries.
if (entry === null || typeof entry !== 'object') {
return '';
}
// Restrict level to safe class-name characters.
let level = escapeClassName(entry.level || 'info');
// Prefer ANSI-rendered HTML from backend, otherwise escape plain text.
let html = typeof entry.html === 'string' && entry.html.includes('<span') === true ? entry.html : escapeHTML(entry.message || '');
return '<div class="log-line log-' + level + '">' + html + '</div>';
})
.join('');
// Restore previous manual scroll position if auto-scroll is disabled
// or if caller explicitly requested no scrolling.
if (scroll !== true || logsAutoScroll !== true) {
logs.scrollTop = logScrollTop;
return;
}
// Defer scroll until after DOM has been updated.
window.setTimeout(() => {
logs.scrollTop = logs.scrollHeight - logs.clientHeight;
}, 0);
}
// Starts the shared frontend runtime timer.
// Handles lightweight local updates every second, plus slower backend polling
// for HomeKit status and page-specific refreshes. This avoids multiple timers
// competing with each other as more dynamic pages are added.
function startRuntimeTimer() {
if (runtimeTimer !== undefined) {
return;
}
runtimeTimer = window.setInterval(async () => {
if (state.authRequired === true) {
return;
}
uptimeSeconds++;
document.querySelectorAll('.uptime').forEach((uptime) => {
uptime.textContent = formatUptime(uptimeSeconds);
});
let now = Date.now();
// Poll general HomeKit/UI status every 30 seconds.
if (now - lastStatusPoll >= 30000) {
lastStatusPoll = now;
try {
let latestInfo = await api('/api/info');
let latestHomeKit = await api('/api/homekit');
state.info = latestInfo;
if (Number.isFinite(Number(latestInfo?.uptime)) === true) {
uptimeSeconds = Number(latestInfo.uptime);
}
applyTheme(state.info.theme);
if (JSON.stringify(latestHomeKit) !== JSON.stringify(state.homekit)) {
state.homekit = latestHomeKit;
if (state.page === 'status') {
scheduleRender();
}
}
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Ignore transient failures
}
}
// Refresh dynamic project pages that request periodic updates.
// Do not refresh while the user is interacting with a form/control,
// otherwise the page can re-render while a select/dropdown is open.
let page = (state.info.pages || []).find((item) => item.id === state.page);
let refreshInterval = Number(page?.refreshInterval);
let activeElement = document.activeElement;
let uiControlActive =
activeElement !== null &&
(activeElement.tagName === 'SELECT' ||
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'BUTTON' ||
activeElement.closest('[data-action]') !== null);
if (
uiControlActive !== true &&
state.page !== 'status' &&
page?.schemaPath === undefined &&
Number.isFinite(refreshInterval) === true &&
refreshInterval > 0 &&
now - lastPageRefresh >= refreshInterval
) {
lastPageRefresh = now;
await loadPageData(page.id);
scheduleRender();
}
}, 1000);
}
// Apply optional project-provided theme colours
function applyTheme(theme) {
if (theme === null || typeof theme !== 'object') {
return;
}
if (typeof theme.accent === 'string' && theme.accent !== '') {
document.documentElement.style.setProperty('--accent', theme.accent);
}
if (typeof theme.accentLight === 'string' && theme.accentLight !== '') {
document.documentElement.style.setProperty('--accent-light', theme.accentLight);
}
if (typeof theme.background === 'string' && theme.background !== '') {
document.documentElement.style.setProperty('--background', theme.background);
}
if (typeof theme.card === 'string' && theme.card !== '') {
document.documentElement.style.setProperty('--card', theme.card);
}
if (typeof theme.text === 'string' && theme.text !== '') {
document.documentElement.style.setProperty('--text', theme.text);
}
}
// Toggle live log appending
function togglePause() {
logsPaused = logsPaused === true ? false : true;
let button = document.getElementById('logs-pause');
if (button !== null) {
button.textContent = logsPaused === true ? 'Live' : 'Pause';
button.title = logsPaused === true ? 'Resume live logs' : 'Pause logs';
}
if (logsPaused === false) {
renderLogsOnly(true);
}
}
// Clear browser-side log view
function clearLogs() {
state.logs = [];
renderLogsOnly(false);
}
// Toggle automatic scrolling when logs arrive
function toggleScroll() {
logsAutoScroll = logsAutoScroll === true ? false : true;
let button = document.getElementById('logs-scroll');
if (button !== null) {
button.textContent = logsAutoScroll === true ? 'Scroll' : 'Manual';
button.title = logsAutoScroll === true ? 'Disable auto-scroll' : 'Enable auto-scroll';
}
if (logsAutoScroll === true) {
renderLogsOnly(true);
}
}
// Toggle a project-provided collapsible section.
// Open state is stored so dynamic page refreshes and browser refreshes can re-apply it.
function toggleCollapse(id) {
let element = document.getElementById(id);
if (element === null) {
return;
}
let storageKey = 'homekitui-collapse-' + state.page + '-' + id;
if (state.collapse[id] === undefined) {
state.collapse[id] = element.classList.contains('open');
}
state.collapse[id] = state.collapse[id] === true ? false : true;
window.localStorage.setItem(storageKey, state.collapse[id] === true ? 'true' : 'false');
element.classList.toggle('open', state.collapse[id] === true);
document.querySelectorAll(`[data-target="${id}"]`).forEach((button) => {
button.classList.toggle('open', state.collapse[id] === true);
});
lastPageRefresh = Date.now();
}
// Re-apply stored collapse state after a page re-render.
function restoreCollapseState() {
if (typeof state.collapse !== 'object' || state.collapse === null) {
return;
}
document.querySelectorAll('.dashboard-collapse').forEach((element) => {
if (typeof element.id !== 'string' || element.id === '') {
return;
}
let storageKey = 'homekitui-collapse-' + state.page + '-' + element.id;
let storedValue = window.localStorage.getItem(storageKey);
if (storedValue !== null) {
state.collapse[element.id] = storedValue === 'true';
}
let isOpen = state.collapse[element.id] === true;
// Restore panel
element.classList.toggle('open', isOpen);
// Restore matching toggle buttons
document.querySelectorAll(`[data-target="${element.id}"]`).forEach((button) => {
button.classList.toggle('open', isOpen);
});
});
}
// Apply visible-switch state for one control.
// Used by trusted project pages for simple frontend-only view switching.
function applyVisibleState(control, value, persist = false) {
if (control === null || control === undefined) {
return;
}
let group = control.dataset.targetGroup;
if (typeof group !== 'string' || group === '') {
return;
}
let selectedValue = String(value ?? control.value);
let storageKey = 'homekitui-visible-' + state.page + '-' + group;
state.visible[group] = selectedValue;
control.value = selectedValue;
if (persist === true) {
window.localStorage.setItem(storageKey, selectedValue);
}
let root = control.closest('[data-visible-root]') || document;
root.querySelectorAll('[data-visible-group="' + group + '"]').forEach((item) => {
item.hidden = item.dataset.visibleValue !== selectedValue;
});
}
// Re-apply stored generic visible-switch state after a page re-render.
function restoreVisibleState() {
if (typeof state.visible !== 'object' || state.visible === null) {
return;
}
document.querySelectorAll('[data-action="switchVisible"]').forEach((control) => {
let group = control.dataset.targetGroup;