homebridge-plugin-utils
Version:
Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.
1,361 lines (1,023 loc) • 141 kB
JavaScript
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* webUi-featureoptions.mjs: Device feature option webUI.
*/
"use strict";
import { FeatureOptions} from "./featureoptions.js";
/**
* @typedef {Object} Device
* @property {string} firmwareRevision - The firmware version of the device.
* @property {string} manufacturer - The manufacturer of the device.
* @property {string} model - The model identifier of the device.
* @property {string} name - The display name of the device.
* @property {string} serialNumber - The unique serial number of the device.
* @property {string} [sidebarGroup] - Optional grouping identifier for sidebar organization.
*/
/**
* @typedef {Object} Controller
* @property {string} address - The network address of the controller.
* @property {string} serialNumber - The unique serial number of the controller.
* @property {string} name - The display name of the controller.
*/
/**
* @typedef {Object} Category
* @property {string} name - The internal name of the category.
* @property {string} description - The user-friendly description of the category.
*/
/**
* @typedef {Object} Option
* @property {string} name - The option name.
* @property {string} description - The user-friendly description.
* @property {boolean} default - The default state of the option.
* @property {*} [defaultValue] - The default value for value-centric options.
* @property {number} [inputSize] - The character width for input fields.
* @property {string} [group] - The parent option this option depends on.
*/
/**
* @typedef {Object} FeatureOptionsConfig
* @property {Function} [getControllers] - Handler to retrieve available controllers.
* @property {Function} [getDevices] - Handler to retrieve devices for a controller.
* @property {Function} [infoPanel] - Handler to display device information.
* @property {Object} [sidebar] - Sidebar configuration options.
* @property {string} [sidebar.controllerLabel="Controllers"] - Label for the controllers section.
* @property {string} [sidebar.deviceLabel="Devices"] - Label for the devices section.
* @property {Function} [sidebar.showDevices] - Handler to display devices in the sidebar.
* @property {Object} [ui] - UI validation and display options.
* @property {number} [ui.controllerRetryEnableDelayMs=5000] - Interval before enabling a retry button when connecting to a controller.
* @property {Function} [ui.isController] - Validates if a device is a controller.
* @property {Function} [ui.validOption] - Validates if an option should display for a device.
* @property {Function} [ui.validOptionCategory] - Validates if a category should display for a device.
*/
/**
* webUiFeatureOptions - Manages the feature options user interface for Homebridge plugins.
*
* This class provides a comprehensive UI for managing hierarchical feature options with support for global, controller-specific, and device-specific settings.
* It implements a three-state checkbox system (checked/unchecked/indeterminate) to show option inheritance and provides search, filtering, and bulk management
* capabilities.
*
* @example
* // Basic usage with default configuration. This creates a feature options UI that reads devices from Homebridge's accessory cache and displays them in a
* // simple device-only mode without controller hierarchy.
* const featureOptionsUI = new webUiFeatureOptions();
* await featureOptionsUI.show();
*
* @example
* // Advanced usage with controller hierarchy and custom device retrieval. This example shows how to configure the UI for a plugin that connects to network
* // controllers which manage multiple devices. The UI will display a three-level hierarchy: global options, controller-specific options, and device-specific
* // options.
* const featureOptionsUI = new webUiFeatureOptions({
* // Custom controller retrieval function. This should return an array of controller objects with address, serialNumber, and name properties.
* getControllers: async () => {
* const controllers = await myPlugin.discoverControllers();
* return controllers.map(c => ({
* address: c.ip,
* serialNumber: c.mac,
* name: c.hostname
* }));
* },
*
* // Custom device retrieval function. When a controller is provided, this should return devices from that controller. When null is provided, it might
* // return cached devices or an empty array depending on your plugin's architecture.
* getDevices: async (controller) => {
* if(!controller) {
* return [];
* }
*
* // Connect to the controller and retrieve its devices. The first device in the array must always be a representation of the controller itself,
* // which allows controller-specific options to be configured.
* const devices = await myPlugin.getDevicesFromController(controller.address);
* return devices;
* },
*
* // Custom information panel. This displays device-specific information in the UI's info panel when a device is selected.
* infoPanel: (device) => {
* if(!device) {
* return;
* }
*
* // Update the info panel with device-specific information. You can show any relevant details here like firmware version, model, status, etc.
* document.getElementById("device_firmware").textContent = device.firmwareRevision || "Unknown";
* document.getElementById("device_model").textContent = device.model || "Unknown";
* document.getElementById("device_status").textContent = device.isOnline ? "Online" : "Offline";
* },
*
* // Customize the sidebar labels. These labels appear as section headers in the navigation sidebar.
* sidebar: {
* controllerLabel: "UniFi Controllers",
* deviceLabel: "Protect Devices"
* },
*
* // UI validation functions. These control which options and categories are displayed for different device types.
* ui: {
* // Determine if a device is actually a controller. Controllers get different options than regular devices.
* isController: (device) => {
* return device?.type === "controller" || device?.isController === true;
* },
*
* // Validate if an option should be shown for a specific device. This allows hiding irrelevant options based on device capabilities.
* validOption: (device, option) => {
* // Don't show camera-specific options for non-camera devices. This keeps the options relevant to each device type.
* if(option.name.startsWith("Video.") && device?.type !== "camera") {
* return false;
* }
*
* // Don't show doorbell options for non-doorbell cameras. This provides fine-grained control over option visibility.
* if(option.name.startsWith("Doorbell.") && !device?.hasChime) {
* return false;
* }
*
* return true;
* },
*
* // Validate if a category should be shown for a specific device. This allows hiding entire categories that don't apply.
* validOptionCategory: (device, category) => {
* // Hide the "Motion Detection" category for devices without motion sensors. This keeps the UI focused and relevant.
* if(category.name === "Motion" && !device?.hasMotionSensor) {
* return false;
* }
*
* return true;
* }
* }
* });
*
* // Display the UI. The show method is async because it needs to load configuration data and potentially connect to controllers.
* await featureOptionsUI.show();
*
* // Clean up when done. This removes all event listeners and frees resources to prevent memory leaks.
* featureOptionsUI.cleanup();
*/
export class webUiFeatureOptions {
// Map of category UI state indexed by context key (serial number or "global").
#categoryStates;
// Table containing the currently displayed feature options.
#configTable;
// The current controller context representing the controller serial number when viewing controller or device options.
#controller;
// Controllers sidebar container element that holds the global options link and controller navigation links.
#controllersContainer;
// The current plugin configuration array retrieved from Homebridge.
currentConfig;
// Container element for device statistics display in the info panel.
#deviceStatsContainer;
// Current list of devices retrieved from either the Homebridge accessory cache or a network controller.
#devices;
// Container element for the list of devices in the sidebar navigation.
#devicesContainer;
// Map of registered event listeners for cleanup management. Keys are unique identifiers, values contain element references and handler details.
#eventListeners;
// Feature options instance that manages the option hierarchy and state logic.
#featureOptions;
// Handler function for retrieving available controllers. Optional - when not provided, the UI operates in device-only mode.
#getControllers;
// Handler function for retrieving devices. Defaults to reading from Homebridge's accessory cache if not provided.
#getDevices;
// Device information panel handler function for displaying device-specific details.
#infoPanel;
// The original set of feature options captured when the UI is first displayed. Used for reverting changes to the last saved state.
#initialFeatureOptions;
// Search panel container element that holds the search input, filters, and status bar.
#searchPanel;
// Sidebar configuration parameters with sensible defaults. Stores labels for controller and device sections.
#sidebar = {
controllerLabel: "Controllers"
};
// Theme color scheme that's currently in use in the Homebridge UI.
#themeColor = {
background: "",
text: ""
};
// Options UI configuration parameters with sensible defaults. Contains validation functions for controllers, options, and categories.
#ui = {
controllerRetryEnableDelayMs: 5000,
isController: () => false,
validOption: () => true,
validOptionCategory: () => true
};
/**
* Initialize the feature options webUI with customizable configuration.
*
* The webUI supports two modes: controller-based (devices grouped under controllers) and direct device mode (devices without controller hierarchy). All
* configuration options are optional and will use sensible defaults if not provided.
*
* @param {FeatureOptionsConfig} options - Configuration options for the webUI.
*/
constructor(options = {}) {
// Extract options with defaults. We destructure here to get clean references to each configuration option while providing fallbacks.
const {
getControllers = undefined,
getDevices = this.getHomebridgeDevices,
infoPanel = this.#showDeviceInfoPanel,
sidebar = {},
ui = {}
} = options;
// Initialize all our properties. We cache DOM elements for performance and maintain state for the current controller and device context.
this.#categoryStates = {};
this.#configTable = document.getElementById("configTable");
this.#controller = null;
this.#controllersContainer = document.getElementById("controllersContainer");
this.currentConfig = [];
this.#deviceStatsContainer = document.getElementById("deviceStatsContainer");
this.#devices = [];
this.#devicesContainer = document.getElementById("devicesContainer");
this.#eventListeners = new Map();
this.#featureOptions = null;
this.#getControllers = getControllers;
this.#getDevices = getDevices;
this.#infoPanel = infoPanel;
this.#searchPanel = document.getElementById("search");
// Merge the provided options with our defaults. This allows partial configuration while maintaining our sensible defaults.
Object.assign(this.#sidebar, sidebar);
Object.assign(this.#ui, ui);
}
/**
* Register an event listener for later cleanup.
*
* This helper ensures we can properly clean up all event listeners when the UI is refreshed or destroyed. This prevents memory leaks that would otherwise
* occur from accumulating event listeners over time.
*
* @param {EventTarget} element - The DOM element to attach the listener to.
* @param {string} event - The event type to listen for.
* @param {EventListener} handler - The event handler function.
* @param {AddEventListenerOptions} [options] - Optional event listener options.
* @returns {string} A unique key that can be used to remove this specific listener.
* @private
*/
#addEventListener(element, event, handler, options) {
// Add the event listener to the element.
element.addEventListener(event, handler, options);
// Store the listener information for cleanup. We generate a unique key using timestamp and random number to ensure uniqueness.
const key = "element-" + Date.now() + "-" + Math.random();
this.#eventListeners.set(key, { element, event, handler, options });
return key;
}
/**
* Remove a specific event listener by its key.
*
* This allows targeted removal of individual event listeners when needed, such as when removing temporary confirmation handlers.
*
* @param {string} key - The unique key returned by #addEventListener.
* @private
*/
#removeEventListener(key) {
const listener = this.#eventListeners.get(key);
if(listener) {
listener.element.removeEventListener(listener.event, listener.handler, listener.options);
this.#eventListeners.delete(key);
}
}
/**
* Clean up all registered event listeners.
*
* This prevents memory leaks when switching views or updating the UI. We iterate through all stored listeners and remove them from their elements before
* clearing our tracking map.
*
* @private
*/
#cleanupEventListeners() {
// Remove all stored event listeners. We use for...of to iterate through the values since we don't need the keys here.
for(const listener of this.#eventListeners.values()) {
listener.element.removeEventListener(listener.event, listener.handler, listener.options);
}
// Clear the map to release all references.
this.#eventListeners.clear();
}
/**
* Initialize event delegation handlers for the entire feature options interface.
*
* This sets up all our event delegation handlers on parent containers. By using event delegation, we can handle events for dynamically created elements
* without attaching individual listeners. This improves performance and memory usage while simplifying our event management.
*
* @private
*/
#initializeEventDelegation() {
// Get the main feature options container for event delegation.
const featureOptionsPage = document.getElementById("pageFeatureOptions");
if(!featureOptionsPage) {
// If we can't find the container, we're likely not in the right context yet.
return;
}
// Handle all sidebar navigation clicks through delegation. This covers global options, controller links, and device links.
this.#addEventListener(featureOptionsPage, "click", async (event) => {
// Check for sidebar navigation links.
const navLink = event.target.closest(".nav-link[data-navigation]");
if(navLink) {
event.preventDefault();
const navigationType = navLink.getAttribute("data-navigation");
// Handle different navigation types.
switch(navigationType) {
case "global":
this.#showGlobalOptions();
break;
case "controller":
await this.#showControllerOptions(navLink.getAttribute("data-device-serial"));
break;
case "device":
this.#showDeviceOptions(navLink.name);
break;
default:
break;
}
return;
}
// Check for filter buttons.
const filterButton = event.target.closest(".btn[data-filter]");
if(filterButton) {
// Determine the class based on the filter type. We start with our default of btn-primary.
let filterClass = "btn-primary";
if(filterButton.getAttribute("data-filter") === "modified") {
filterClass = "btn-warning text-dark";
}
// Create our parameters to pass along to the click handler.
const filterConfig = {
class: filterClass,
filter: filterButton.getAttribute("data-filter-type") ?? filterButton.getAttribute("data-filter"),
text: filterButton.textContent
};
this.#handleFilterClick(filterButton, filterConfig);
return;
}
// Handle expanding and collapsing all feature option categories when toggled.
const toggleButton = event.target.closest("#toggleAllCategories");
if(toggleButton) {
this.#handleToggleClick(toggleButton);
return;
}
// Handle any button with a reset-related action.
const resetButton = event.target.closest(".btn[data-action^='reset']");
if(resetButton) {
const action = resetButton.getAttribute("data-action");
const resetDefaultsBtn = resetButton.parentElement.querySelector("button[data-action='reset-defaults']");
const resetRevertBtn = resetButton.parentElement.querySelector("button[data-action='reset-revert']");
switch(action) {
case "reset-toggle":
resetDefaultsBtn?.classList.toggle("d-none");
resetRevertBtn?.classList.toggle("d-none");
resetButton.textContent = resetDefaultsBtn?.classList.contains("d-none") ? "Reset..." : "\u25B6";
break;
case "reset-defaults":
case "reset-revert":
if(action === "reset-defaults") {
await this.#resetAllOptions();
} else {
await this.#revertToInitialOptions();
}
resetButton.classList.toggle("d-none");
resetRevertBtn?.classList.toggle("d-none");
break;
default:
break;
}
return;
}
// Handle expanding or collapsing a feature option category when its header is clicked.
const headerCell = event.target.closest("table[data-category] thead th");
if(headerCell) {
const table = headerCell.closest("table");
const tbody = table.querySelector("tbody");
if(tbody) {
const isCollapsed = tbody.style.display === "none";
// Use the shared method to update the state.
this.#setCategoryState(table, !isCollapsed);
document.getElementById("toggleAllCategories")?.updateState?.();
// Save the state after toggling this category.
const currentContext = this.#getCurrentContextKey();
if(currentContext) {
this.#saveCategoryStates(currentContext);
}
}
return;
}
// Check for option labels (but not if clicking on inputs). Clicking a label toggles the associated checkbox for better UX.
const labelCell = event.target.closest("td.option-label");
if(labelCell && !event.target.closest("input")) {
labelCell.closest("tr").querySelector("input[type='checkbox']")?.click();
}
});
// Handle checkbox changes through delegation.
this.#addEventListener(featureOptionsPage, "change", (event) => {
// Check for option checkboxes.
if(event.target.matches("input[type='checkbox']")) {
const checkbox = event.target;
const optionName = checkbox.id;
const deviceSerial = checkbox.getAttribute("data-device-serial");
// Find the option in the feature options.
const categoryName = event.target.closest("table[data-category]")?.getAttribute("data-category");
if(!categoryName) {
return;
}
const option = this.#featureOptions.options[categoryName]?.find(opt => this.#featureOptions.expandOption(categoryName, opt) === optionName);
if(!option) {
return;
}
if(option) {
const device = deviceSerial ? this.#devices.find(device => device.serialNumber === deviceSerial) : null;
const label = checkbox.closest("tr").querySelector(".option-label label");
const inputValue = checkbox.closest("tr").querySelector("input[type='text']");
this.#handleOptionChange(checkbox, optionName, option, device, label, inputValue);
}
return;
}
// Check for value inputs. When a text input changes, we trigger the checkbox change event to update the configuration.
if(event.target.matches("input[type='text']")) {
event.target.closest("tr").querySelector("input[type='checkbox']")?.dispatchEvent(new Event("change", { bubbles: true }));
return;
}
});
// Handle search input through delegation with debouncing for performance.
this.#addEventListener(featureOptionsPage, "input", (event) => {
if(event.target.matches("#searchInput")) {
const searchInput = event.target;
if(searchInput._searchTimeout) {
clearTimeout(searchInput._searchTimeout);
}
searchInput._searchTimeout = setTimeout(() => {
this.#handleSearch(searchInput.value.trim(),
[...document.querySelectorAll("#configTable tbody tr")],
[...document.querySelectorAll("#configTable table")],
searchInput._originalVisibility || new Map()
);
}, 300);
}
});
// Handle keyboard events for search shortcuts and navigation.
this.#addEventListener(featureOptionsPage, "keydown", (event) => {
// Handle escape key in search input to clear the search.
if(event.target.matches("#searchInput") && (event.key === "Escape")) {
event.target.value = "";
event.target.dispatchEvent(new Event("input", { bubbles: true }));
}
// Ctrl/Cmd+F to focus search when the search panel is visible.
if((event.ctrlKey || event.metaKey) && (event.key === "f")) {
const searchInput = document.getElementById("searchInput");
if(searchInput && this.#searchPanel && (this.#searchPanel.style.display !== "none")) {
event.preventDefault();
searchInput.focus();
searchInput.select();
}
}
// Allow expanding/collapsing categories via keyboard. We support Enter and Space as expected.
if(event.target.matches("table[data-category] thead th") && ((event.key === "Enter") || (event.key === " "))) {
event.preventDefault();
const headerCell = event.target.closest("table[data-category] thead th");
if(headerCell) {
headerCell.click();
}
}
});
}
/**
* Create a DOM element with optional properties and children.
*
* This helper reduces the verbosity of DOM manipulation throughout the code. It handles common patterns like setting classes, styles, and adding children
* in a more functional style.
*
* @param {string} tag - The HTML tag name to create.
* @param {Object} [props={}] - Properties to set on the element.
* @param {string|string[]|Array} [props.classList] - CSS classes to add.
* @param {Object} [props.style] - Inline styles to apply.
* @param {Array<string|Node>} [children=[]] - Child nodes or text content.
* @returns {HTMLElement} The created DOM element.
* @private
*/
#createElement(tag, props = {}, children = []) {
const element = document.createElement(tag);
// Apply any CSS classes. We handle both single classes and arrays, making the API flexible for callers.
if(props.classList) {
const classes = Array.isArray(props.classList) ? props.classList : props.classList.split(" ");
element.classList.add(...classes);
delete props.classList;
}
// Apply any inline styles. We use Object.assign for efficiency when setting multiple style properties at once.
if(props.style) {
Object.assign(element.style, props.style);
delete props.style;
}
// Apply all other properties. This handles standard DOM properties like id, name, type, etc.
for(const [ key, value ] of Object.entries(props)) {
// Data attributes and other hyphenated attributes need setAttribute.
if(key.includes("-")) {
element.setAttribute(key, value);
} else {
element[key] = value;
}
}
// Add any children, handling both elements and text nodes. Text strings are automatically converted to text nodes for proper DOM insertion.
for(const child of children) {
element.appendChild((typeof child === "string") ? document.createTextNode(child) : child);
}
return element;
}
/**
* Toggle CSS classes on an element more elegantly.
*
* This utility helps manage the common pattern of adding and removing classes based on state changes, particularly useful for highlighting selected items.
*
* @param {HTMLElement} element - The element to modify.
* @param {string[]} [add=[]] - Classes to add.
* @param {string[]} [remove=[]] - Classes to remove.
* @private
*/
#toggleClasses(element, add = [], remove = []) {
for(const cls of remove) {
element.classList.remove(cls);
}
for(const cls of add) {
element.classList.add(cls);
}
}
/**
* Set the expansion state of a category table.
*
* @param {HTMLTableElement} table - The category table element.
* @param {boolean} isCollapsed - True to collapse, false to expand.
* @private
*/
#setCategoryState(table, isCollapsed) {
const tbody = table.querySelector("tbody");
const arrow = table.querySelector(".arrow");
const headerCell = table.querySelector("thead th[role='button']");
if(tbody) {
tbody.style.display = isCollapsed ? "none" : "";
}
if(arrow) {
arrow.textContent = isCollapsed ? "\u25B6 " : "\u25BC ";
}
if(headerCell) {
headerCell.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
}
}
/**
* Hide the feature options webUI and clean up all resources.
*
* This hides the UI elements and calls cleanup to remove all event listeners and free resources. This method should be called when switching away from the
* feature options view or when the plugin configuration UI is being destroyed.
*
* @returns {Promise<void>}
* @public
*/
hide() {
// Hide the UI elements until we're ready to show them. This prevents visual flickering as we build the interface.
for(const id of [ "deviceStatsContainer", "headerInfo", "optionsContainer", "search", "sidebar" ]) {
const element = document.getElementById(id);
if(element) {
element.style.display = "none";
}
}
this.cleanup();
}
/**
* Show global options in the main content area.
*
* This displays the feature options that apply globally to all controllers and devices. It clears the devices container and resets the device list since
* global options don't have associated devices.
*
* @private
*/
#showGlobalOptions() {
// Save the current UI state before switching contexts.
const previousContext = this.#getCurrentContextKey();
if(previousContext && this.#configTable.querySelector("table[data-category]")) {
this.#saveCategoryStates(previousContext);
}
// Clear the devices container since global options don't have associated devices, but only when we have controllers defined.
if(this.#getControllers) {
this.#devicesContainer.textContent = "";
}
// Highlight the global options entry
this.#highlightSelectedController(null);
// Show global options
this.#showDeviceOptions("Global Options");
}
/**
* Show controller options by loading its devices and displaying the controller's configuration.
*
* This displays the feature options for a specific controller. It finds the controller by its serial number and loads its associated devices.
*
* @param {string} controllerSerial - The serial number of the controller to show options for.
* @private
*/
async #showControllerOptions(controllerSerial) {
// Save the current UI state before switching contexts.
const previousContext = this.#getCurrentContextKey();
if(previousContext && this.#configTable.querySelector("table[data-category]")) {
this.#saveCategoryStates(previousContext);
}
const entry = (await this.#getControllers())?.find(c => c.serialNumber === controllerSerial);
if(!entry) {
return;
}
await this.#showSidebar(entry);
}
/**
* Render the feature options webUI.
*
* This is the main entry point for displaying the UI. It handles all initialization, loads the current configuration, and sets up the interface. The method
* is async because it needs to fetch configuration data from Homebridge and potentially connect to network controllers.
*
* @returns {Promise<void>}
* @public
*/
async show() {
// Show the beachball while we setup. The user needs feedback that something is happening during the async operations.
homebridge.showSpinner();
homebridge.hideSchemaForm();
// Update our menu button states to show we're on the feature options page. This provides visual navigation feedback to the user.
this.#updateMenuState();
// Show the feature options page and hide the support page. These are mutually exclusive views in the Homebridge UI.
document.getElementById("pageSupport").style.display = "none";
document.getElementById("pageFeatureOptions").style.display = "block";
// Hide the UI elements and cleanup any listeners until we're ready to show them. This prevents visual flickering as we build the interface.
this.hide();
// Make sure we have the refreshed configuration. This ensures we're always working with the latest saved settings.
this.currentConfig = await homebridge.getPluginConfig();
// Load any persisted UI states from localStorage.
try {
const storageKey = "homebridge-" + (this.currentConfig[0]?.platform ?? "plugin") + "-category-states";
const stored = window.localStorage.getItem(storageKey);
if(stored) {
this.#categoryStates = JSON.parse(stored);
}
// eslint-disable-next-line no-unused-vars
} catch(error) {
this.#categoryStates = {};
}
// Keep our revert snapshot aligned with whatever was *last saved* (not just first render).
// We compare to the current config and update the snapshot if it differs, so "Revert to Saved" reflects the latest saved state.
const loadedOptions = (this.currentConfig[0]?.options ?? []);
if(!this.#initialFeatureOptions || !this.#sameStringArray(this.#initialFeatureOptions, loadedOptions)) {
this.#initialFeatureOptions = [...loadedOptions];
}
// Retrieve the set of feature options available to us. This comes from the plugin backend and defines what options can be configured.
const features = (await homebridge.request("/getOptions")) ?? [];
// Initialize our feature option configuration. This creates the data structure that manages option states and hierarchies.
this.#featureOptions = new FeatureOptions(features.categories, features.options, this.currentConfig[0].options ?? []);
// Clear all our containers to start fresh. This ensures no stale content remains from previous displays.
this.#clearContainers();
// Ensure the DOM is ready before we render our UI. We wait for Bootstrap styles to be applied before proceeding.
await this.#waitForBootstrap();
// Initialize theme sync before injecting styles so CSS variables are defined and current.
await this.#setupThemeAutoUpdate();
// Add our custom styles for hover effects, dark mode support, and modern layouts. These enhance the visual experience and ensure consistency with the
// Homebridge UI theme.
this.#injectCustomStyles();
// Initialize event delegation for all UI interactions.
this.#initializeEventDelegation();
// Hide the search panel initially until content is loaded.
if(this.#searchPanel) {
this.#searchPanel.style.display = "none";
}
// Check if we have controllers configured when they're required. We can't show device options without at least one controller in controller mode.
if(this.#getControllers && !(await this.#getControllers())?.length) {
this.#showNoControllersMessage();
homebridge.hideSpinner();
return;
}
// Initialize our informational header with feature option precedence information. This helps users understand the inheritance hierarchy.
this.#initializeHeader();
// Build the sidebar with global options and controllers/devices. This creates the navigation structure for the UI.
await this.#buildSidebar();
// All done. Let the user interact with us.
homebridge.hideSpinner();
// Default the user to the global settings if we have no controllers. Otherwise, show the first controller to give them a starting point.
await this.#showSidebar((await this.#getControllers?.())?.[0] ?? null);
}
/**
* Wait for Bootstrap to finish loading in the DOM so we can render our UI properly, or until the timeout expires.
*
* This ensures that we've loaded all the CSS resources needed to provide our visual interface. If Bootstrap doesn't load within the timeout period, we
* proceed anyway to avoid blocking the UI indefinitely.
*
* @param {number} [timeoutMs=2000] - Maximum time to wait for Bootstrap in milliseconds.
* @param {number} [intervalMs=20] - Interval between checks in milliseconds.
* @returns {Promise<boolean>} True if Bootstrap was detected, false if timeout was reached.
* @private
*/
async #waitForBootstrap(timeoutMs = 2000, intervalMs = 20) {
// Record when we started so we know how long we have been waiting.
const startTime = Date.now();
// This helper checks whether Bootstrap's styles are currently applied.
const isBootstrapApplied = () => {
// We create a temporary test element and apply the "d-none" class.
const testElem = document.createElement("div");
testElem.className = "d-none";
document.body.appendChild(testElem);
// If Bootstrap is loaded, the computed display value should be "none".
const display = getComputedStyle(testElem).display;
// Remove our test element to avoid leaving behind clutter.
document.body.removeChild(testElem);
// Return true if the Bootstrap style is detected.
return display === "none";
};
// We loop until Bootstrap is detected or we reach our timeout.
while(Date.now() - startTime < timeoutMs) {
// If Bootstrap is active, we can stop waiting.
if(isBootstrapApplied()) {
return true;
}
// Otherwise, we pause for a short interval before checking again.
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
return false;
};
/**
* Update the menu button states to reflect the current page.
*
* This provides visual feedback about which section of the plugin config the user is currently viewing. We swap between the elegant and primary button
* styles to show active/inactive states.
*
* @private
*/
#updateMenuState() {
const menuStates = [
{ id: "menuHome", primary: true },
{ id: "menuFeatureOptions", primary: false },
{ id: "menuSettings", primary: true }
];
for(const { id, primary } of menuStates) {
this.#toggleClasses(document.getElementById(id),
primary ? ["btn-primary"] : ["btn-elegant"],
primary ? ["btn-elegant"] : ["btn-primary"]);
}
}
/**
* Clear all containers to prepare for fresh content.
*
* This ensures we don't have any stale data when switching between controllers or refreshing the view. We also reset our controller and device lists to
* maintain consistency between the UI state and the displayed content.
*
* @private
*/
#clearContainers() {
for(const id of [ "controllersContainer", "devicesContainer", "configTable" ]) {
const container = document.getElementById(id);
if(container) {
container.textContent = "";
}
}
}
/**
* Show a message when no controllers are configured.
*
* This provides clear guidance to the user about what they need to do before they can configure feature options. Without controllers, there's nothing to
* configure in controller mode.
*
* @private
*/
#showNoControllersMessage() {
const headerInfo = document.getElementById("headerInfo");
headerInfo.textContent = "Please configure a controller to access in the main settings tab before configuring feature options.";
headerInfo.style.display = "";
}
/**
* Initialize the informational header showing feature option precedence.
*
* This header educates users about how options inherit through the hierarchy. Understanding this inheritance model is crucial for effective configuration,
* so we make it prominent at the top of the interface. The header adapts based on whether controllers are being used.
*
* @private
*/
#initializeHeader() {
const headerInfo = document.getElementById("headerInfo");
if(headerInfo) {
headerInfo.style.fontWeight = "bold";
headerInfo.innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:" +
"<br><i class=\"text-warning\">Global options</i> (lowest priority) → " +
(this.#getControllers ? "<i class=\"text-success\">Controller options</i> → " : "") +
"<i class=\"text-info\">Device options</i> (highest priority)";
}
}
/**
* Build the sidebar with global options and controllers.
*
* The sidebar provides the primary navigation for the feature options UI. It always includes a global options entry and optionally includes controllers if
* the plugin is configured to use them. The sidebar structure determines how users navigate between different configuration scopes.
*
* @private
*/
async #buildSidebar() {
// Create the global options entry - this is always present. Global options apply to all devices and provide baseline configuration.
this.#createGlobalOptionsEntry(this.#controllersContainer);
// Create controller entries if we're using controllers. Controllers provide an intermediate level of configuration between global and device-specific.
if(this.#getControllers) {
await this.#createControllerEntries(this.#controllersContainer);
}
}
/**
* Create the global options entry in the sidebar.
*
* Global options are always available and provide the baseline configuration that all controllers and devices inherit from. This entry is styled differently
* to indicate its special status and is added to the appropriate tracking list based on whether controllers are present.
*
* @param {HTMLElement} controllersContainer - The container to add the entry to.
* @private
*/
#createGlobalOptionsEntry(controllersContainer) {
const globalLink = this.#createElement("a", {
classList: [ "nav-link", "nav-header", "text-decoration-none", "text-uppercase", "fw-bold" ],
"data-navigation": "global",
href: "#",
name: "Global Options",
role: "button"
}, ["Global Options"]);
controllersContainer.appendChild(globalLink);
}
/**
* Create controller entries in the sidebar.
*
* Controllers represent network devices that manage multiple accessories. Each controller gets its own entry in the sidebar, allowing users to configure
* options at the controller level that apply to all its devices. Controllers are displayed with their configured names for easy identification.
*
* @param {HTMLElement} controllersContainer - The container to add entries to.
* @private
*/
async #createControllerEntries(controllersContainer) {
// If we don't have controllers defined, we're done.
if(!this.#getControllers) {
return;
}
// Create the controller category header. This visually groups all controllers together and uses the configured label.
const categoryHeader = this.#createElement("h6", {
classList: [ "nav-header", "text-muted", "text-uppercase", "small", "mb-1" ]
}, [this.#sidebar.controllerLabel]);
controllersContainer.appendChild(categoryHeader);
// Create an entry for each controller. Controllers are identified by their serial number and displayed with their friendly name.
for(const controller of (await this.#getControllers())) {
const link = this.#createElement("a", {
classList: [ "nav-link", "text-decoration-none" ],
"data-device-serial": controller.serialNumber,
"data-navigation": "controller",
href: "#",
name: controller.serialNumber,
role: "button"
}, [controller.name]);
controllersContainer.appendChild(link);
}
}
/**
* Show the device list taking the controller context into account.
*
* This method handles the navigation when a user clicks on a controller or global options in the sidebar. It loads the appropriate devices and displays the
* feature options for the selected context. For controllers, it loads devices from the network. For global options, it shows global configuration.
*
* @param {Controller|null} controller - The controller to show devices for, or null for global options.
* @returns {Promise<void>}
* @private
*/
async #showSidebar(controller) {
// Show the beachball while we setup. Loading devices from a controller can take time, especially over the network.
homebridge.showSpinner();
// Grab the list of devices we're displaying. This might involve a network request to the controller or reading from the Homebridge cache.
this.#devices = await this.#getDevices(controller);
if(this.#getControllers) {
// Highlight the selected controller. This provides visual feedback about which controller's devices we're currently viewing.
this.#highlightSelectedController(controller);
// Handle connection errors. If we can't connect to a controller, we need to inform the user rather than showing an empty list.
if(controller && !this.#devices?.length) {
await this.#showConnectionError();
return;
}
// The first entry returned by getDevices() must always be the controller. This convention allows us to show controller-specific options.
this.#controller = this.#devices[0]?.serialNumber ?? null;
}
// Make the UI visible. Now that we have our data, we can show the interface elements to the user.
for(const id of ["headerInfo"]) {
const element = document.getElementById(id);
if(element) {
element.style.display = "";
}
}
// The sidebar should always be visible unless there's an error.
const sidebar = document.getElementById("sidebar");
if(sidebar) {
sidebar.style.display = "";
}
// Clear and populate the devices container. #showSidebarDevices is responsible for the actual display logic.
this.#devicesContainer.textContent = "";
this.#showSidebarDevices(controller, this.#devices);
// Display the feature options to the user. For controllers, we show the controller's options. For global context, we show global options.
this.#showDeviceOptions(controller ? this.#devices[0].serialNumber : "Global Options");
// All done. Let the user interact with us.
homebridge.hideSpinner();
}
/**
* Highlight the selected controller in the sidebar.
*
* This provides visual feedback about which controller is currently selected. We use the active class to indicate selection, which works well in both
* light and dark modes thanks to our custom styles. The highlighting helps users maintain context as they navigate.
*
* @param {Controller|null} controller - The selected controller, or null for global options.
* @private
*/
#highlightSelectedController(controller) {
const selectedName = controller?.serialNumber ?? "Global Options";
for(const entry of document.querySelectorAll("#sidebar .nav-link[data-navigation]")) {
this.#toggleClasses(entry, (entry.name === selectedName) ? ["active"] : [], (entry.name === selectedName) ? [] : ["active"]);
}
}
/**
* Show a connection error message with retry capability.
*
* When we can't connect to a controller, we need to provide clear feedback about what went wrong. This helps users troubleshoot configuration issues. The
* error message includes details from the backend and offers a retry button after a short delay.
*
* @returns {Promise<void>}
* @private
*/
async #showConnectionError() {
// Hide the sidebar and other UI elements that don't make sense without a connection.
const sidebar = document.getElementById("sidebar");
if(sidebar) {
sidebar.style.display = "none";
}
if(this.#deviceStatsContainer) {
this.#deviceStatsContainer.style.display = "none";
}
if(this.#searchPanel) {
this.#searchPanel.style.display = "none";
}
// Clear all containers to remove any stale content.
this.#clearContainers();
const headerInfo = document.getElementById("headerInfo");
const errorMessage = [
"Unable to connect to the controller.",
"Check the Settings tab to verify the controller details are correct.",
"<code class=\"text-danger\">" + (await homebridge.request("/getErrorMessage")) + "</code>"
].join("<br>") + "<br>";
// Create a container div for the error message and future retry button. This allows us to add the button without replacing the entire content.
const errorContainer = this.#createElement("div", {}, []);
errorContainer.innerHTML = errorMessage;
headerInfo.textContent = "";
headerInfo.appendChild(errorContainer);
headerInfo.style.display = "";
// Wrapper that shrink-wraps its children
const retryWrap = this.#createElement("div", { classList: "d-inline-block w-auto" });
// Create the retry button with consistent styling. We use the warning style to indicate this is a recovery action.
const retryButton = this.#createElement("button", {
classList: "btn btn-warning btn-sm mt-3",
textContent: "\u21BB Retry",
type: "button"
});
retryButton.disabled = true;
// Add the button to the error container. It appears below the error message with appropriate spacing.
retryWrap.appendChild(retryButton);
// Add a slim progress bar that fills for 5s.
const barWrap = this.#createElement("div", {
classList: "progress mt-1 w-100",
style: { height: "4px" }
}, [
this.#createElement("div", {
classList: "progress-bar",
role: "progressbar",
style: { width: "0%" }
})
]);
retryWrap.appendChild(barWrap);
errorContainer.appendChild(retryWrap);
// Kick off the fill animation on the next frame
const bar = barWrap.querySelector(".progress-bar");
bar.style.setProperty("--bs-progress-bar-bg", this.#themeColor.background);
window.requestAnimationFrame(() => {
bar.style.transition = "width " + this.#ui.controllerRetryEnableDelayMs + "ms linear";
bar.style.width = "100%";
});
// After five seconds, enable the retry button. The delay prevents the UI from appearing too busy immediately after an error and gives users time to read the
// error message before seeing the action they can take.
setTimeout(() => {
retryButton.disabled = false;
barWrap.remove();
// Set up the retry handler. When clicked, we'll refresh the entire UI which will retry all connections and rebuild the interface.
this.#addEventListener(retryButton, "click", async () => {
// Provide immediate feedback that we're retrying. The button becomes disabled to prevent multiple simultaneous retry attempts.
retryButton.disabled = true;
retryButton.textContent = "Retrying...";
// Refresh our UI which will force a reconnection.
this.cleanup();
await this.show();
});
}, this.#ui.controllerRetryEnableDelayMs);
homebridge.hideSpinner();
}
/**
* Show feature option information for a specific device, controller, or globally.
*
* This is the main method for displaying feature options. It handles all three contexts (global, controller, device) and builds the appropriate UI elements
* including search, filters, and the option tables themselves. The display adapts based on the current scope and available options.
*
* @param {string} deviceId - The device serial number, or "Global Options" for global context.
* @public
*/
#showDeviceOptions(deviceId) {
homebridge.showSpinner();
// Retrieve our current context before we change the active link.
const previousActive = this.#devicesContainer.querySelector(".nav-link.active[data-navigation='device']");
// Save the current category UI state before switching contexts.
if(previousActive && (previousActive.name !== deviceId) && this.#configTable.querySelector("table[data-category]")) {
this.#saveCategoryStates(previousActive.name);
}
// Clean up event listeners from previous option displays. This ensures we don't accumulate listeners as users navigate between devices.
this.#cleanupOptionEventListeners();
// Update the selected device highlighting. This provides visual feedback in the sidebar about which device's options are being displayed.
this.#highlightSelectedDevice(deviceId);
// Find the curr