UNPKG

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
/* 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) &rarr; " + (this.#getControllers ? "<i class=\"text-success\">Controller options</i> &rarr; " : "") + "<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