garagedoor-accfactory
Version:
HomeKit garage door opener system using HAP-NodeJS library
1,169 lines (996 loc) • 43.9 kB
JavaScript
// Module: HomeKitUI
//
// Shared web UI module for HomeKit-enabled standalone applications.
// Designed to provide a lightweight, Homebridge-like setup and maintenance
// interface for accessories built using HAP-NodeJS.
//
// Provides a simple browser-based interface for configuring, maintaining,
// and managing HomeKit accessories without requiring Homebridge.
//
// Responsibilities:
// - Serve the built-in HomeKitUI web interface
// - Expose config.json, config.schema.json, and config.ui.schema.json for UI rendering
// - Provide generic project page data through a single page API endpoint
// - Handle configuration save, validation, backup, and restore workflows
// - Provide HomeKit pairing details (QR code, setup URI, pairing state)
// - Stream logs from a file, journald or console capture
// - Support resetting HomeKit pairing data via HAP-NodeJS cleanup
// - Provide optional hooks for restart and maintenance actions
// - Provide optional bearer-token authentication for API/UI access
//
// Architecture:
// - Intended to be used alongside HomeKitDevice-based projects
// - Operates at the application/bridge level (not per-device instance)
// - HomeKitUI owns the application shell, status page, logs, and maintenance pages
// - Host projects provide configuration schema, optional pages, and page data hooks
// - UI communicates with the host application via API endpoints and hooks
// - Authentication is handled centrally via Express middleware before route handling
//
// API Endpoints:
// - GET /api/info
// - GET /api/config
// - POST /api/config
// - GET /api/schema
// - GET /api/ui-schema
// - GET /api/page/:id
// - GET /api/homekit
// - POST /api/homekit/reset
// - POST /api/service/restart
// - GET /api/logs
// - GET /api/logs/stream
// - GET /api/backup
// - POST /api/restore
// - POST /api/action
//
// Authentication:
// - Optional bearer-token authentication for all API endpoints
// - Uses standard HTTP Authorization: Bearer <token> header
// - SSE log streaming supports token query parameter fallback because
// native browser EventSource does not support custom headers
// - Authentication tokens are supplied by the host application
// - Host application remains responsible for token generation/persistence
//
// Notes:
// - Designed for HAP-NodeJS standalone environments (not Homebridge)
// - Does not manage accessory lifecycle or publishing directly
// - Host application remains responsible for device creation and runtime control
// - Resetting HomeKit pairing removes all controllers and requires re-pairing
// - Explicit log file is preferred when configured
// - Journald is preferred in auto mode when running under systemd
// - Console capture is used as fallback for direct/manual runs
// - Built-in UI is always served from this module's ui folder
// - Default host binding follows Express behaviour unless explicitly configured
//
// Mark Hulskamp
'use strict';
// Define external module requirements
import express from 'express';
import QRCode from 'qrcode';
import { AnsiUp } from 'ansi_up';
// Define nodejs module requirements
import console from 'node:console';
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import util from 'node:util';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
// Define constants
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATIC_PATH = path.join(__dirname, 'ui');
const LOG_LEVELS = {
INFO: 'info',
SUCCESS: 'success',
WARN: 'warn',
ERROR: 'error',
DEBUG: 'debug',
};
// Define our HomeKit UI class
export default class HomeKitUI {
static DEFAULT_PORT = 8581;
static VERSION = '2026.05.07';
// Shared console capture state
static DEFAULT_CONSOLE_HISTORY_LINES = 500;
static #consoleCaptured = false; // Prevent double-patching console.*
static #consoleHistory = []; // Recent console output for non-systemd/direct runs
static #consoleListeners = new Set(); // Live console listeners for SSE clients
static #consoleOriginal = {}; // Original console methods before capture
// Internal data only for this class
#ansi = new AnsiUp(); // ANSI output to HTML converter for UI consumers
#app = undefined; // Express app instance
#logListeners = new Map(); // Active SSE response -> cleanup callback map
#options = {}; // Runtime options
#server = undefined; // HTTP server instance
constructor(options = {}) {
// Options must be a plain object. If not, fall back to defaults so the class
// can still be constructed safely and fail later with useful endpoint errors.
if (options === null || typeof options !== 'object' || options.constructor !== Object) {
options = {};
}
// Inline styles keep HomeKitUI self-contained and avoid browser-side ansi_up dependency.
this.#ansi.use_classes = false;
// Store all runtime options in one internal object so the public class surface
// remains small. Hooks allow each standalone app to provide its own validation,
// saving, restart, pairing reset, and generic page data behaviour.
this.#options = {
name: 'HomeKit Device',
version: HomeKitUI.VERSION,
port: HomeKitUI.DEFAULT_PORT,
host: '127.0.0.1',
auth: {},
configFile: undefined,
schemaFile: undefined,
uiSchemaFile: undefined,
theme: {},
pages: [],
accessory: undefined,
accessories: [],
hap: undefined,
log: undefined,
logs: {},
onGetPage: undefined,
onValidateConfig: undefined,
onSaveConfig: undefined,
onAction: undefined,
onRestoreConfig: undefined,
onRestart: undefined,
onResetPairing: undefined,
...options,
};
this.#normaliseOptions();
HomeKitUI.#captureConsole(this.#options.logs.lines);
}
async start(options = {}) {
// Runtime options may be supplied at start time because some values, like the
// HAP accessory, may not exist until after the application has initialised.
if (options !== null && typeof options === 'object' && options.constructor === Object) {
this.#options = {
...this.#options,
...options,
};
}
this.#normaliseOptions();
HomeKitUI.#captureConsole(this.#options.logs.lines);
// If the HTTP server is already running, don't bind twice. This makes start()
// safe to call from app initialisation code that may be retried.
if (this.#server !== undefined) {
return false;
}
// Avoid Node/Express treating port 0 as "pick a random port". For HomeKitUI,
// disabled should be handled by the host project, but this guard keeps the
// module safe if an invalid port is accidentally passed in.
if (Number.isFinite(Number(this.#options.port)) !== true || Number(this.#options.port) <= 0 || Number(this.#options.port) > 65535) {
this.#log(LOG_LEVELS.INFO, 'HomeKitUI disabled');
return false;
}
this.#options.port = Number(this.#options.port);
// Create a new Express application for our API and built-in static UI assets.
this.#app = express();
// Accept JSON payloads for config save/restore and maintenance actions.
// Limit is intentionally small since configs should not be large.
this.#app.use(express.json({ limit: '2mb' }));
// Register core API routes. Keep routes flat and explicit so the built-in UI can
// remain simple and the host app has a predictable API contract.
this.#app.use('/api', this.#handleAuthentication.bind(this));
this.#app.get('/api/info', this.#handleInfo.bind(this));
this.#app.get('/api/config', this.#handleGetConfig.bind(this));
this.#app.post('/api/config', this.#handleSaveConfig.bind(this));
this.#app.get('/api/schema', this.#handleGetSchema.bind(this));
this.#app.get('/api/ui-schema', this.#handleGetUISchema.bind(this));
this.#app.get('/api/page/:id', this.#handlePage.bind(this));
this.#app.post('/api/action', this.#handleAction.bind(this));
this.#app.get('/api/homekit', this.#handleHomeKit.bind(this));
this.#app.post('/api/homekit/reset', this.#handleResetPairing.bind(this));
this.#app.post('/api/service/restart', this.#handleRestart.bind(this));
this.#app.get('/api/logs', this.#handleLogs.bind(this));
this.#app.get('/api/logs/stream', this.#handleLogStream.bind(this));
this.#app.get('/api/backup', this.#handleBackup.bind(this));
this.#app.post('/api/restore', this.#handleRestore.bind(this));
// The web app shell is owned by HomeKitUI and is not project-overridable.
// Host projects extend the UI by providing schema/ui-schema/page metadata.
this.#app.use(express.static(STATIC_PATH));
// Client-side routing support. Any non-API route returns the built-in UI entry.
this.#app.use((request, response) => {
response.sendFile(path.join(STATIC_PATH, 'index.html'));
});
// Start listening on either a specific host or localhost by default.
// HomeKitUI now defaults to loopback-only binding for safer standalone
// deployments unless the host application explicitly exposes another interface.
await new Promise((resolve) => {
if (typeof this.#options.host === 'string' && this.#options.host !== '') {
this.#server = this.#app.listen(this.#options.port, this.#options.host, resolve);
} else {
this.#server = this.#app.listen(this.#options.port, resolve);
}
});
this.#log(LOG_LEVELS.SUCCESS, 'Setup HomeKitUI for "%s"', this.#options.name);
this.#log(
LOG_LEVELS.INFO,
' += Listening on "%s:%s"%s',
typeof this.#options.host === 'string' && this.#options.host !== '' ? this.#options.host : 'localhost',
this.#options.port,
this.#options.auth.enabled === true ? ' (authentication enabled)' : '',
);
this.#sanitisePages(this.#options.pages).forEach((page) => {
this.#log(LOG_LEVELS.DEBUG, ' += Added page "%s"', page.title);
});
return true;
}
async stop() {
// No server means there is nothing to stop. Return false so the caller can tell
// whether this call actually changed anything.
if (this.#server === undefined) {
return false;
}
// Remove live file/journal/console listeners and close any active log
// streams before shutting down the HTTP server.
for (let [response, cleanup] of this.#logListeners) {
try {
if (typeof cleanup === 'function') {
cleanup();
}
response.end();
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
}
this.#logListeners.clear();
// Close the HTTP server gracefully. This only stops the web UI; it does not
// unpublish or remove the HomeKit accessory.
await new Promise((resolve) => {
this.#server.close(resolve);
});
// Drop references so the instance can be started again later if required.
this.#server = undefined;
this.#app = undefined;
return true;
}
#handleAuthentication(request, response, next) {
// Authentication is optional so existing standalone applications can continue
// operating exactly as before unless bearer-token protection is explicitly enabled.
if (this.#options.auth.enabled !== true) {
next();
return;
}
let token = undefined;
// Prefer standard HTTP Authorization header using Bearer authentication.
// This keeps API access compatible with curl, reverse proxies, and scripts.
let authHeader = typeof request.headers?.authorization === 'string' ? request.headers.authorization.trim() : '';
if (authHeader.startsWith('Bearer ') === true) {
token = authHeader.slice(7).trim();
}
// Native browser EventSource connections do not support custom headers.
// Allow token authentication via query parameter specifically for the
// live log streaming endpoint used by the built-in UI.
if (token === undefined && request.path === '/logs/stream' && typeof request.query?.token === 'string') {
token = request.query.token.trim();
}
// Reject requests if:
// - no configured bearer token exists
// - configured token is invalid/empty
// - supplied token does not match configured token
//
// Intentionally return a generic authentication error rather than exposing
// whether authentication is enabled or partially configured.
if (
typeof this.#options.auth.bearerToken !== 'string' ||
this.#options.auth.bearerToken === '' ||
token !== this.#options.auth.bearerToken
) {
response.status(401).json({ error: 'Authentication required' });
return;
}
// Authentication successful, continue processing the request.
next();
}
async #handleInfo(request, response) {
// Return metadata used by the built-in UI header/sidebar. Project-provided pages
// are included here so the UI shell can add extra navigation items dynamically.
response.json({
name: this.#options.name,
version: this.#options.version,
uiVersion: HomeKitUI.VERSION,
port: this.#options.port,
uptime: process.uptime(),
pages: this.#sanitisePages(this.#options.pages),
theme: this.#options.theme ?? {},
});
}
async #handleGetConfig(request, response) {
try {
// Load the current configuration from disk every time so the UI reflects
// external edits made while the service is running.
response.json(await this.#readJsonFile(this.#options.configFile));
} catch (error) {
this.#sendError(response, error);
}
}
async #handleSaveConfig(request, response) {
try {
// Config saves must be plain JSON objects. Arrays or primitive values would
// not match the expected config file layout for the standalone apps.
if (request.body === null || typeof request.body !== 'object' || request.body.constructor !== Object) {
throw new TypeError('Invalid configuration supplied');
}
// Let the host application perform project-specific validation. This is where
// GPIO validation, HomeKit pin validation, schema validation, or migration can run.
if (typeof this.#options.onValidateConfig === 'function') {
await this.#options.onValidateConfig(request.body);
}
// Prefer host-controlled saving when provided. This allows the app to preserve
// formatting, regenerate defaults, or update runtime state before writing.
if (typeof this.#options.onSaveConfig === 'function') {
await this.#options.onSaveConfig(request.body);
} else {
await this.#writeJsonFile(this.#options.configFile, request.body);
}
// Restart requirement is evaluated by the frontend using changed paths and
// schema/page metadata. Restore/reset endpoints still force restart where needed.
response.json({ ok: true });
} catch (error) {
this.#sendError(response, error);
}
}
async #handleGetSchema(request, response) {
try {
// Serve the JSON Schema that drives the generated form. The built-in UI can
// use this with RJSF or another schema renderer to mimic Homebridge Config UI.
response.json(await this.#readJsonFile(this.#options.schemaFile));
} catch (error) {
this.#sendError(response, error);
}
}
async #handleGetUISchema(request, response) {
try {
// UI schema is optional. If not supplied, return an empty object so the
// frontend can still render the configuration form from the JSON Schema.
if (typeof this.#options.uiSchemaFile !== 'string' || this.#options.uiSchemaFile === '') {
response.json({});
return;
}
response.json(await this.#readJsonFile(this.#options.uiSchemaFile));
} catch (error) {
this.#sendError(response, error);
}
}
async #handlePage(request, response) {
try {
// Disable browser caching for dynamic page data.
// Without this, Safari will return 304 (Not Modified) and you won’t see updates.
// This endpoint is dynamic (runtime state), so it must always be fresh.
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
response.setHeader('Pragma', 'no-cache');
response.setHeader('Expires', '0');
// Project pages are intentionally generic. HomeKitUI does not know about
// tanks, zones, cameras, locks, or any other project-specific concepts.
// The host project returns renderer-friendly data for the requested page id.
let id = typeof request.params?.id === 'string' ? request.params.id : undefined;
if (typeof id !== 'string' || id === '') {
throw new Error('Invalid page id');
}
// Ensure requested page actually exists in configured pages list.
if (this.#hasPage(id) === false) {
response.status(404).json({ error: 'Unknown page' });
return;
}
// Delegate page data generation to host application.
// This keeps HomeKitUI completely generic and reusable.
if (typeof this.#options.onGetPage === 'function') {
let data = await this.#options.onGetPage(id);
// Always return a plain object to keep frontend logic simple.
if (data === null || typeof data !== 'object') {
response.json({});
return;
}
response.json(data);
return;
}
// No handler provided, return empty payload.
response.json({});
} catch (error) {
this.#sendError(response, error);
}
}
async #handleAction(request, response) {
try {
// Actions are optional project-defined commands from dynamic pages.
// HomeKitUI does not interpret the action itself; it only validates the
// payload shape and passes it to the host application.
if (request.body === null || typeof request.body !== 'object' || request.body.constructor !== Object) {
throw new TypeError('Invalid action supplied');
}
let action = typeof request.body?.action === 'string' && request.body.action !== '' ? request.body.action : undefined;
let page = typeof request.body?.page === 'string' && request.body.page !== '' ? request.body.page : undefined;
let data =
request.body?.data !== undefined && typeof request.body.data === 'object' && request.body.data !== null ? request.body.data : {};
if (action === undefined) {
throw new Error('Invalid action id');
}
if (page !== undefined && this.#hasPage(page) === false) {
response.status(404).json({ error: 'Unknown page' });
return;
}
if (typeof this.#options.onAction !== 'function') {
response.status(501).json({ error: 'Action hook not configured' });
return;
}
await this.#options.onAction(action, data, page);
response.json({ ok: true });
} catch (error) {
this.#sendError(response, error);
}
}
async #handleHomeKit(request, response) {
try {
// Build HomeKit status objects for the UI. This keeps QR generation,
// setup URI handling, and pairing state logic out of the frontend.
response.json(await this.#homeKitDetails());
} catch (error) {
this.#sendError(response, error);
}
}
async #handleResetPairing(request, response) {
try {
let details = await this.#homeKitDetails();
// Prefer an explicit username from the request, but normally the first
// available accessory username is used. Username is the HAP MAC-style identifier.
let username = typeof request.body?.username === 'string' && request.body.username !== '' ? request.body.username : details.username;
let accessory = this.#findAccessoryByUsername(username);
if (typeof username !== 'string' || username === '') {
throw new Error('Cannot reset HomeKit pairing because no accessory username is available');
}
// If the host application supplied a reset hook, delegate to it.
if (typeof this.#options.onResetPairing === 'function') {
await this.#options.onResetPairing(username, accessory);
} else {
// Default reset flow for HAP-NodeJS accessories:
//
// 1. Remove HAP-NodeJS pairing/persist data for the selected username.
// 2. Let the host application restart the process.
//
// Do NOT call accessory.unpublish() or accessory.destroy() here.
// HAP-NodeJS/bonjour teardown can race during active advertisement.
//
// This will remove pairings for the selected accessory. It must be re-added in Home.
if (typeof this.#options.hap?.Accessory?.cleanupAccessoryData === 'function') {
this.#options.hap.Accessory.cleanupAccessoryData(username);
} else if (typeof accessory?.constructor?.cleanupAccessoryData === 'function') {
accessory.constructor.cleanupAccessoryData(username);
} else {
throw new Error('HAP-NodeJS cleanupAccessoryData() is not available');
}
if (typeof this.#options.onRestart === 'function') {
response.json({ ok: true, restartRequired: true });
await this.#options.onRestart();
return;
}
}
response.json({ ok: true, restartRequired: true });
} catch (error) {
this.#sendError(response, error);
}
}
async #handleRestart(request, response) {
try {
// Restart is intentionally delegated to the host app. This module should not
// assume systemd, PM2, Docker, launchd, or direct process management.
if (typeof this.#options.onRestart !== 'function') {
response.status(501).json({ error: 'Restart hook not configured' });
return;
}
// Register the finish handler BEFORE sending the response so we cannot miss
// the event if Express flushes the response immediately.
response.once('finish', () => {
this.#options.onRestart().catch((error) => {
this.#log(LOG_LEVELS.ERROR, String(error?.stack ?? error));
});
});
// Send success response before triggering restart. Some restart handlers may
// terminate the HTTP server or exit the process immediately, so waiting for
// the hook before responding can leave the browser with a failed request.
response.json({ ok: true, restartRequired: true });
} catch (error) {
this.#sendError(response, error);
}
}
async #handleLogs(request, response) {
try {
let source = await this.#logSource();
// Explicit file source wins. This allows a host app to point HomeKitUI at any
// rotating or application-managed log file.
if (source === 'file') {
response.json({ logs: await this.#readLogFile(this.#options.logs.file, this.#options.logs.lines) });
return;
}
// Journald is preferred in auto mode when running under systemd. It survives
// process restarts and avoids relying on in-process memory.
if (source === 'journald') {
response.json({ logs: await this.#readJournal(this.#options.logs.lines) });
return;
}
// Console fallback is useful for direct/manual runs where systemd and a log
// file are not available.
response.json({ logs: HomeKitUI.#consoleHistory.map((entry) => this.#logEntry(entry.terminal, entry.level, entry.time)) });
} catch (error) {
this.#sendError(response, error);
}
}
async #handleLogStream(request, response) {
// Keep an HTTP connection open so log entries can be pushed to the browser.
response.setHeader('Content-Type', 'text/event-stream');
response.setHeader('Cache-Control', 'no-cache');
response.setHeader('Connection', 'keep-alive');
// Initial event confirms the stream is open. Browser EventSource will reconnect
// automatically if this connection drops.
response.write('event: connected\n');
response.write('data: true\n\n');
let source = await this.#logSource();
// Explicit file source wins. `tail -F` follows rotation/replacement better than
// `tail -f`, which is useful when the host app or system rotates logs.
if (source === 'file') {
this.#streamCommand(response, 'tail', ['-n', '0', '-F', this.#options.logs.file]);
} else if (source === 'journald') {
// Journald stream uses -o cat so ANSI escape codes are preserved for the UI.
this.#streamCommand(response, 'journalctl', [...(await this.#journalArgs(0)), '-f']);
} else {
// Console stream is the last fallback for direct/manual runs.
let closed = false;
let cleanup = () => {
if (closed === true) {
return;
}
closed = true;
HomeKitUI.#consoleListeners.delete(listener);
this.#logListeners.delete(response);
};
let listener = (entry) => {
try {
response.write('data: ' + JSON.stringify(this.#logEntry(entry.terminal, entry.level, entry.time)) + '\n\n');
} catch {
cleanup();
}
};
HomeKitUI.#consoleListeners.add(listener);
this.#logListeners.set(response, cleanup);
}
// Remove closed clients so we don't leak response handles, child processes, or listeners.
request.on('close', () => {
let cleanup = this.#logListeners.get(response);
if (typeof cleanup === 'function') {
cleanup();
}
});
}
async #handleBackup(request, response) {
try {
// Backup is just the current config file returned as a download. This keeps
// backup/restore simple and transparent for the user.
response.setHeader('Content-Type', 'application/json');
response.setHeader('Content-Disposition', 'attachment; filename="config.backup.json"');
response.send(JSON.stringify(await this.#readJsonFile(this.#options.configFile), null, 2) + '\n');
} catch (error) {
this.#sendError(response, error);
}
}
async #handleRestore(request, response) {
try {
// Restore uses the same plain-object requirement as normal config save.
if (request.body === null || typeof request.body !== 'object' || request.body.constructor !== Object) {
throw new TypeError('Invalid configuration supplied');
}
// Validate before writing so a broken backup cannot silently overwrite the
// working configuration unless the host validator allows it.
if (typeof this.#options.onValidateConfig === 'function') {
await this.#options.onValidateConfig(request.body);
}
// Allow the host app to handle restore differently from normal save. For
// example, it may want to keep the existing HomeKit username/pin.
if (typeof this.#options.onRestoreConfig === 'function') {
await this.#options.onRestoreConfig(request.body);
} else {
await this.#writeJsonFile(this.#options.configFile, request.body);
}
response.json({ ok: true, restartRequired: true });
} catch (error) {
this.#sendError(response, error);
}
}
async #homeKitDetails() {
let accessories = this.#accessories();
let items = [];
for (let accessory of accessories) {
items.push(await this.#accessoryHomeKitDetails(accessory));
}
// Return the new multi-accessory payload while preserving the original top-level
// fields for older frontends or simple single-accessory projects.
return {
accessories: items,
accessory: items[0],
displayName: items[0]?.displayName,
username: items[0]?.username,
pincode: items[0]?.pincode,
setupID: items[0]?.setupID,
setupURI: items[0]?.setupURI,
qrCode: items[0]?.qrCode,
paired: items[0]?.paired === true,
pairings: items[0]?.pairings ?? [],
};
}
async #accessoryHomeKitDetails(accessory) {
// HAP-NodeJS generates the HomeKit setup URI from the accessory pairing details.
// This is the same payload that the QR code encodes.
let setupURI = typeof accessory?.setupURI === 'function' ? accessory.setupURI() : undefined;
// Convert setup URI into a browser-displayable PNG data URL. If setupURI is not
// available, QR code is left undefined and the UI can fall back to setup code.
let qrCode = setupURI !== undefined ? await QRCode.toDataURL(setupURI) : undefined;
// HAP-NodeJS stores live pairing state internally. This is not as clean as a
// public API, but is the practical way to mirror the Homebridge UI behaviour.
let accessoryInfo = accessory?._accessoryInfo;
let pairings = [];
// Pairing list is optional depending on HAP-NodeJS version. If it fails, hide
// the list rather than failing the whole HomeKit page.
if (typeof accessoryInfo?.listPairings === 'function') {
try {
pairings = accessoryInfo.listPairings();
// eslint-disable-next-line no-unused-vars
} catch (error) {
pairings = [];
}
}
// Return a frontend-friendly object. The UI can render the QR image directly
// from qrCode, show setupURI for debugging, and use paired to control warnings.
return {
displayName: accessory?.displayName,
username: accessory?.username ?? accessory?.lastKnownUsername ?? accessoryInfo?.username,
pincode: accessory?.pincode ?? accessoryInfo?.pincode,
setupID: accessory?._setupID ?? accessoryInfo?.setupID,
setupURI,
qrCode,
paired: typeof accessoryInfo?.paired === 'function' ? accessoryInfo.paired() === true : false,
pairings,
};
}
#findAccessoryByUsername(username) {
if (typeof username !== 'string' || username === '') {
return undefined;
}
return this.#accessories().find((accessory) => {
let accessoryInfo = accessory?._accessoryInfo;
return accessory?.username === username || accessory?.lastKnownUsername === username || accessoryInfo?.username === username;
});
}
#accessories() {
let accessories = [];
if (Array.isArray(this.#options.accessories) === true) {
accessories = this.#options.accessories.filter((accessory) => accessory !== undefined && accessory !== null);
}
if (accessories.length === 0 && this.#options.accessory !== undefined && this.#options.accessory !== null) {
accessories = [this.#options.accessory];
}
return accessories;
}
async #readJsonFile(file) {
// Require a valid file path before attempting disk access. This gives a useful
// API error if the host app forgot to configure configFile/schemaFile.
if (typeof file !== 'string' || file === '') {
throw new Error('JSON file path not configured');
}
// Parse and return JSON. Any syntax error is intentionally allowed to throw so
// the UI can surface a clear configuration/schema problem.
return JSON.parse(await fs.readFile(file, 'utf8'));
}
async #writeJsonFile(file, data) {
// Require a valid file path before writing so bad setup cannot write somewhere
// unexpected or fail with a cryptic fs error.
if (typeof file !== 'string' || file === '') {
throw new Error('JSON file path not configured');
}
// Write formatted JSON with a trailing newline to match the style used by the
// standalone config files.
await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n');
}
async #readLogFile(file, lines) {
// File logs are read as plain text and converted into UI log entries line by line.
// This intentionally reads the whole file for simplicity; host apps should pass
// a rotated/sensible log file rather than huge archival logs.
let content = await fs.readFile(file, 'utf8');
return content
.split('\n')
.filter((line) => line.trim() !== '')
.slice(-lines)
.map((line) => this.#logEntry(line));
}
async #readJournal(lines) {
let entries = [];
let buffer = '';
let args = await this.#journalArgs(lines);
await new Promise((resolve) => {
let finished = false;
let proc = spawn('journalctl', args);
let finish = () => {
if (finished === true) {
return;
}
finished = true;
if (buffer.trim() !== '') {
entries.push(this.#logEntry(buffer));
}
resolve();
};
proc.stdout.on('data', (data) => {
buffer += String(data);
let lines = buffer.split('\n');
buffer = lines.pop() ?? '';
lines.forEach((line) => {
if (line.trim() !== '') {
entries.push(this.#logEntry(line));
}
});
});
proc.on('close', finish);
proc.on('error', finish);
});
return entries;
}
async #journalArgs(lines) {
// Prefer an explicitly supplied systemd unit when configured.
// Unit-based journald queries include previous service runs, so UI scrollback works.
let unit = typeof this.#options.logs.unit === 'string' && this.#options.logs.unit !== '' ? this.#options.logs.unit : undefined;
// Try to infer the service unit from /proc/self/cgroup.
if (unit === undefined) {
try {
let cgroup = await fs.readFile('/proc/self/cgroup', 'utf8');
let match = cgroup.match(/(?:^|\/)([^/\n]+\.service)(?:\/|$)/);
if (Array.isArray(match) === true && typeof match[1] === 'string' && match[1] !== '') {
unit = match[1];
}
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
}
// If cgroup did not expose the unit, ask journald which unit owns this PID.
if (unit === undefined) {
try {
let json = '';
await new Promise((resolve) => {
let proc = spawn('journalctl', ['_PID=' + process.pid, '-n', '1', '-o', 'json', '--no-pager']);
proc.stdout.on('data', (data) => {
json += String(data);
});
proc.on('close', resolve);
proc.on('error', resolve);
});
let entry = JSON.parse(json.trim() || '{}');
if (typeof entry?._SYSTEMD_UNIT === 'string' && entry._SYSTEMD_UNIT !== '') {
unit = entry._SYSTEMD_UNIT;
}
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
}
if (unit !== undefined) {
return ['-u', unit, '-o', 'cat', '-n', String(lines), '--no-pager'];
}
// Last resort only. Invocation id is intentionally narrow and only shows this run.
if (typeof process.env.INVOCATION_ID === 'string' && process.env.INVOCATION_ID !== '') {
return ['_SYSTEMD_INVOCATION_ID=' + process.env.INVOCATION_ID, '-o', 'cat', '-n', String(lines), '--no-pager'];
}
return [];
}
async #logSource() {
// Log source priority:
// 1. Explicit file path (always wins)
// 2. journald (preferred under systemd or when explicitly requested)
// 3. console fallback (direct/manual runs)
// Explicit file override
if (typeof this.#options.logs.file === 'string' && this.#options.logs.file !== '') {
return 'file';
}
// Explicit source selection
if (this.#options.logs.source === 'file') {
return typeof this.#options.logs.file === 'string' && this.#options.logs.file !== '' ? 'file' : 'console';
}
if (this.#options.logs.source === 'journald') {
return (await this.#journalArgs(this.#options.logs.lines)).length > 0 ? 'journald' : 'console';
}
if (this.#options.logs.source === 'console') {
return 'console';
}
// Auto mode (default)
if ((await this.#journalArgs(this.#options.logs.lines)).length > 0) {
return 'journald';
}
return 'console';
}
#streamCommand(response, command, args) {
// Stream a child process line-by-line into SSE. Used by both tail and journalctl.
let buffer = '';
let proc = spawn(command, args);
let closed = false;
let cleanup = () => {
if (closed === true) {
return;
}
closed = true;
this.#logListeners.delete(response);
try {
proc.kill('SIGTERM');
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
try {
response.end();
// eslint-disable-next-line no-unused-vars
} catch (error) {
// Empty
}
};
this.#logListeners.set(response, cleanup);
proc.stdout.on('data', (data) => {
buffer += String(data);
let lines = buffer.split('\n');
buffer = lines.pop() ?? '';
lines.forEach((line) => {
if (line.trim() !== '') {
try {
response.write('data: ' + JSON.stringify(this.#logEntry(line)) + '\n\n');
} catch {
cleanup();
}
}
});
});
proc.on('error', cleanup);
proc.on('close', cleanup);
}
#logEntry(line, level = LOG_LEVELS.INFO, time = new Date().toISOString()) {
// Convert raw terminal text into the structured object expected by app.js.
return {
time,
level,
message: line,
terminal: line,
html: this.#ansi.ansi_to_html(line),
};
}
#normaliseOptions() {
// Normalise option groups after constructor/start merges so callers can provide
// partial options without needing to mirror the full defaults object.
if (Array.isArray(this.#options.pages) === false) {
this.#options.pages = [];
}
if (Array.isArray(this.#options.accessories) === false) {
this.#options.accessories = [];
}
if (this.#options.logs === null || typeof this.#options.logs !== 'object' || this.#options.logs.constructor !== Object) {
this.#options.logs = {};
}
if (this.#options.auth === null || typeof this.#options.auth !== 'object' || this.#options.auth.constructor !== Object) {
this.#options.auth = {};
}
this.#options.auth = {
enabled: this.#options.auth.enabled === true,
bearerToken:
typeof this.#options.auth.bearerToken === 'string' && this.#options.auth.bearerToken.trim() !== ''
? this.#options.auth.bearerToken.trim()
: undefined,
};
this.#options.logs = {
source: typeof this.#options.logs.source === 'string' && this.#options.logs.source !== '' ? this.#options.logs.source : 'auto',
file: typeof this.#options.logs.file === 'string' && this.#options.logs.file !== '' ? this.#options.logs.file : undefined,
unit: typeof this.#options.logs.unit === 'string' && this.#options.logs.unit !== '' ? this.#options.logs.unit : undefined,
lines:
Number.isFinite(Number(this.#options.logs.lines)) === true && Number(this.#options.logs.lines) > 0
? Number(this.#options.logs.lines)
: HomeKitUI.DEFAULT_CONSOLE_HISTORY_LINES,
};
}
#hasPage(id) {
// Only allow requests for pages explicitly advertised by the host project.
// This prevents arbitrary ids from reaching the project's page data hook.
return this.#sanitisePages(this.#options.pages).some((page) => page.id === id);
}
#sanitisePages(pages = []) {
// Project pages are treated as metadata only. Sanitise them before exposing to
// the frontend so a bad project config cannot inject arbitrary values.
if (Array.isArray(pages) === false) {
return [];
}
return pages
.filter((page) => page !== null && typeof page === 'object' && page.constructor === Object)
.map((page) => ({
id: typeof page.id === 'string' && page.id !== '' ? page.id : undefined,
title: typeof page.title === 'string' && page.title !== '' ? page.title : undefined,
icon: typeof page.icon === 'string' && page.icon !== '' ? page.icon : undefined,
svg: typeof page.svg === 'string' && page.svg !== '' ? page.svg : undefined,
schemaPath:
typeof page.schemaPath === 'string' &&
page.schemaPath !== '' &&
/^[a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*$/.test(page.schemaPath) === true
? page.schemaPath
: undefined,
restartRequired: page.restartRequired === true ? true : page.restartRequired === false ? false : undefined,
refreshInterval:
Number.isFinite(Number(page.refreshInterval)) === true && Number(page.refreshInterval) > 0
? Number(page.refreshInterval)
: undefined,
trustedHTML: page.trustedHTML === true ? true : undefined,
}))
.filter((page) => page.id !== undefined && page.title !== undefined);
}
#sendError(response, error) {
// Normalise all API errors through one path so logging and client responses are
// consistent across config, HomeKit, and maintenance endpoints.
this.#log(LOG_LEVELS.ERROR, String(error?.stack ?? error));
response.status(500).json({ error: String(error?.message ?? error) });
}
#log(level, message, ...args) {
// Ignore invalid log messages. This keeps endpoint error handling safe even if
// a caller passes unusual values.
if (typeof message !== 'string' || message === '') {
return;
}
// Forward internal HomeKitUI logs to the host application's logger if provided.
// This is not used for log streaming, only for UI/service-level messages.
if (typeof this.#options.log?.[level] === 'function') {
this.#options.log[level](message, ...args);
}
}
static #captureConsole(lines = HomeKitUI.DEFAULT_CONSOLE_HISTORY_LINES) {
// Patch console once so direct/manual runs still have a live log source when
// journald and file logs are unavailable.
if (HomeKitUI.#consoleCaptured === true) {
return;
}
// Ensure lines is a valid positive integer. Fallback to default if invalid.
lines = Number.isFinite(Number(lines)) === true && Number(lines) > 0 ? Number(lines) : HomeKitUI.DEFAULT_CONSOLE_HISTORY_LINES;
HomeKitUI.#consoleCaptured = true;
// Preserve original console methods so we can still output to stdout/stderr
// after intercepting log calls.
HomeKitUI.#consoleOriginal.log = console.log;
HomeKitUI.#consoleOriginal.info = console.info;
HomeKitUI.#consoleOriginal.warn = console.warn;
HomeKitUI.#consoleOriginal.error = console.error;
HomeKitUI.#consoleOriginal.debug = console.debug;
[
['log', LOG_LEVELS.INFO],
['info', LOG_LEVELS.INFO],
['warn', LOG_LEVELS.WARN],
['error', LOG_LEVELS.ERROR],
['debug', LOG_LEVELS.DEBUG],
].forEach(([method, level]) => {
console[method] = (...args) => {
// Format console arguments into a single string using Node.js util.format
let line = util.format(...args);
// Construct a log entry that can be consumed by the UI or streamed via SSE
let entry = {
time: new Date().toISOString(),
level,
message: line,
terminal: line, // Preserve raw terminal output for streaming
};
// Append to in-memory history buffer
HomeKitUI.#consoleHistory.push(entry);
// Trim history buffer to configured size using splice (O(n)),
// replacing the previous shift() loop (O(n²) under heavy load)
if (HomeKitUI.#consoleHistory.length > lines) {
HomeKitUI.#consoleHistory.splice(0, HomeKitUI.#consoleHistory.length - lines);
}
// Notify active listeners (e.g. SSE log stream clients)
HomeKitUI.#consoleListeners.forEach((listener) => {
try {
listener(entry);
} catch {
// Ignore listener errors to avoid breaking logging
}
});
// Forward the original console call so logs still appear normally
HomeKitUI.#consoleOriginal[method](...args);
};
});
}
}