UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

840 lines (589 loc) 31.5 kB
/* Copyright(C) 2017-2025, HJD (https://github.com/hjdhjd). All rights reserved. * * webUi-featureoptions.mjs: Device feature option webUI. */ "use strict"; import { FeatureOptions} from "./featureoptions.js"; export class webUiFeatureOptions { // Table containing the currently displayed feature options. #configTable; // The current controller context. #controller; // The current plugin configuration. currentConfig; // Table containing the details on the currently selected device. deviceStatsTable; // Current list of devices from the Homebridge accessory cache. #devices; // Table containing the list of devices. devicesTable; // Feature options instance. #featureOptions; // Get devices handler. #getDevices; // Enable the use of controllers. #hasControllers; // Device information panel handler. #infoPanel; // Sidebar configuration parameters. #sidebar; // Options UI configuration parameters. #ui; // Current list of controllers, for webUI elements. webUiControllerList; // Current list of devices on a given controller, for webUI elements. webUiDeviceList; /** * Display the feature option webUI. All webUI configuration settings are optional. * * getDevices - return an array of displays to be displayed. * hasControllers - true (default) if the plugin hierarchically has controllers and then devices managed by each controller, rather than just devices. * infoPanel - handler to display information in the device detail information panel. * ui - customize which options are displayed in the feature option webUI: * isController - validate whether a given device is a controller. Returns true or false. * validOption - validate whether an option is valid on a given device (or controller). * validCategory - validate whether a category of options is valid for a given device (or controller). * sidebar - customize the sidebar for the feature option webUI: * controllerLabel - label to use for the controllers category. Defaults to "Controllers". * deviceLabel - label to use for the devices category. Defaults to "Devices". * showDevices - handler for enumerating devices in the sidebar. */ constructor(options = {}) { // Defaults for the feature option webUI sidebar. this.#ui = { isController: () => false, validOption: () => true, validOptionCategory: () => true }; // Defaults for the feature option webUI sidebar. this.#sidebar = { controllerLabel: "Controllers", deviceLabel: "Devices", showDevices: this.#showSidebarDevices.bind(this) }; // Defaults for the feature option webUI. const { getDevices = this.#getHomebridgeDevices, hasControllers = true, infoPanel = this.#showDeviceInfoPanel, sidebar = {}, ui = {} } = options; this.#configTable = document.getElementById("configTable"); this.#controller = null; this.currentConfig = []; this.deviceStatsTable = document.getElementById("deviceStatsTable"); this.#devices = []; this.devicesTable = document.getElementById("devicesTable"); this.#featureOptions = null; this.#getDevices = getDevices; this.#hasControllers = hasControllers; this.#infoPanel = infoPanel; this.#sidebar = Object.assign({}, this.#sidebar, sidebar); this.#ui = Object.assign({}, this.#ui, ui); this.webUiControllerList = []; this.webUiDeviceList = []; } /** * Render the feature options webUI. */ async show() { // Show the beachball while we setup. homebridge.showSpinner(); homebridge.hideSchemaForm(); // Create our custom UI. document.getElementById("menuHome").classList.remove("btn-elegant"); document.getElementById("menuHome").classList.add("btn-primary"); document.getElementById("menuFeatureOptions").classList.add("btn-elegant"); document.getElementById("menuFeatureOptions").classList.remove("btn-primary"); document.getElementById("menuSettings").classList.remove("btn-elegant"); document.getElementById("menuSettings").classList.add("btn-primary"); // Hide the legacy UI. document.getElementById("pageSupport").style.display = "none"; document.getElementById("pageFeatureOptions").style.display = "block"; // Make sure we have the refreshed configuration. this.currentConfig = await homebridge.getPluginConfig(); // Retrieve the set of feature options available to us. const features = (await homebridge.request("/getOptions")) ?? []; // Initialize our feature option configuration. this.#featureOptions = new FeatureOptions(features.categories, features.options, this.currentConfig[0].options ?? []); // We render our global options, followed by either a list of controllers (if so configured) or by a list of devices from the Homebridge accessory cache. // Retrieve the table for the our list of controllers and global options. const controllersTable = document.getElementById("controllersTable"); // Start with a clean slate. controllersTable.innerHTML = ""; this.devicesTable.innerHTML = ""; this.#configTable.innerHTML = ""; this.webUiDeviceList = []; // Create our override styles for things like hover for our sidebar and workarounds for Homebridge UI quirks. const overrideStyles = document.createElement("style"); // We want to override the default colors that Homebridge UI might apply for table cells. overrideStyles.innerHTML = "td { color: unset !important }"; // We emulate the styles that Bootstrap uses when hovering over a table, accounting for both light and dark modes. overrideStyles.innerHTML += "@media (prefers-color-scheme: dark) { .hbup-hover td:hover { background-color: #212121; color: #FFA000 !important } }" + "@media (prefers-color-scheme: light) { .hbup-hover td:hover { background-color: #ECECEC; } }"; document.head.appendChild(overrideStyles); // Add our hover styles to the controllers and devices tables. controllersTable.classList.add("hbup-hover"); this.devicesTable.classList.add("hbup-hover"); // Hide the UI until we're ready. document.getElementById("sidebar").style.display = "none"; document.getElementById("headerInfo").style.display = "none"; document.getElementById("deviceStatsTable").style.display = "none"; // If we haven't configured any controllers, we're done. if(this.#hasControllers && !this.currentConfig[0]?.controllers?.length) { document.getElementById("headerInfo").innerHTML = "Please configure a controller to access in the main settings tab before configuring feature options."; document.getElementById("headerInfo").style.display = ""; homebridge.hideSpinner(); return; } // Initialize our informational header. document.getElementById("headerInfo").style.fontWeight = "bold"; document.getElementById("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.#hasControllers ? "<i class=\"text-success\">Controller options</i> &rarr; " : "") + "<i class=\"text-info\">Device options</i> (highest priority)"; // Enumerate our global options. const trGlobal = document.createElement("tr"); // Create the cell for our global options. const tdGlobal = document.createElement("td"); tdGlobal.classList.add("m-0", "p-0", "w-100"); // Create our label target. const globalLabel = document.createElement("label"); globalLabel.name = "Global Options"; globalLabel.appendChild(document.createTextNode("Global Options")); globalLabel.style.cursor = "pointer"; globalLabel.classList.add("m-0", "p-0", "pl-1", "w-100"); globalLabel.addEventListener("click", () => this.#showSidebar(null)); // Add the global options label. tdGlobal.appendChild(globalLabel); tdGlobal.style.fontWeight = "bold"; // Add the global cell to the table. trGlobal.appendChild(tdGlobal); // Now add it to the overall controllers table. controllersTable.appendChild(trGlobal); // Add it as another controller of device, for UI purposes. (this.#hasControllers ? this.webUiControllerList : this.webUiDeviceList).push(globalLabel); if(this.#hasControllers) { // Create a row for our controllers. const trController = document.createElement("tr"); // Disable any pointer events and hover activity. trController.style.pointerEvents = "none"; // Create the cell for our controller category row. const tdController = document.createElement("td"); tdController.classList.add("m-0", "p-0", "pl-1", "w-100"); // Add the category name, with appropriate casing. tdController.appendChild(document.createTextNode(this.#sidebar.controllerLabel)); tdController.style.fontWeight = "bold"; // Add the cell to the table row. trController.appendChild(tdController); // Add the table row to the table. controllersTable.appendChild(trController); for(const controller of this.currentConfig[0].controllers) { // Create a row for this controller. const trDevice = document.createElement("tr"); trDevice.classList.add("m-0", "p-0"); // Create a cell for our controller. const tdDevice = document.createElement("td"); tdDevice.classList.add("m-0", "p-0", "w-100"); const label = document.createElement("label"); label.name = controller.address; label.appendChild(document.createTextNode(controller.address)); label.style.cursor = "pointer"; label.classList.add("mx-2", "my-0", "p-0", "w-100"); label.addEventListener("click", () => this.#showSidebar(controller)); // Add the controller label to our cell. tdDevice.appendChild(label); // Add the cell to the table row. trDevice.appendChild(tdDevice); // Add the table row to the table. controllersTable.appendChild(trDevice); this.webUiControllerList.push(label); } } // All done. Let the user interact with us. homebridge.hideSpinner(); // Default the user on our global settings if we have no controller. this.#showSidebar(this.#hasControllers ? this.currentConfig[0].controllers[0] : null); } // Show the device list taking the controller context into account. async #showSidebar(controller) { // Show the beachball while we setup. homebridge.showSpinner(); // Grab the list of devices we're displaying. this.#devices = await this.#getDevices(controller); if(this.#hasControllers) { // Make sure we highlight the selected controller so the user knows where we are. this.webUiControllerList.map(webUiEntry => (webUiEntry.name === (controller ? controller.address : "Global Options")) ? webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white")); // Unable to connect to the controller for some reason. if(controller && !this.#devices?.length) { this.devicesTable.innerHTML = ""; this.#configTable.innerHTML = ""; document.getElementById("headerInfo").innerHTML = ["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>"); document.getElementById("headerInfo").style.display = ""; this.deviceStatsTable.style.display = "none"; homebridge.hideSpinner(); return; } // The first entry returned by getDevices() must always be the controller. this.#controller = this.#devices[0]?.serialNumber ?? null; } // Make the UI visible. document.getElementById("headerInfo").style.display = ""; document.getElementById("sidebar").style.display = ""; // Wipe out the device list, except for our global entry. this.webUiDeviceList.splice(1, this.webUiDeviceList.length); // Start with a clean slate. this.devicesTable.innerHTML = ""; // Populate our devices sidebar. this.#sidebar.showDevices(controller, this.#devices); // Display the feature options to the user. this.showDeviceOptions(controller ? this.#devices[0].serialNumber : "Global Options"); // All done. Let the user interact with us. homebridge.hideSpinner(); } // Show feature option information for a specific device, controller, or globally. showDeviceOptions(deviceId) { homebridge.showSpinner(); // Update the selected device for visibility. this.webUiDeviceList.map(webUiEntry => (webUiEntry.name === deviceId) ? webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white")); // Populate the device information info pane. const currentDevice = this.#devices.find(device => device.serialNumber === deviceId); // Populate the details view. If there's no device specified, the context is considered global and we hide the device details view. if(!currentDevice) { this.deviceStatsTable.style.display = "none"; } this.#infoPanel(currentDevice); if(currentDevice) { this.deviceStatsTable.style.display = ""; } // Start with a clean slate. this.#configTable.innerHTML = ""; for(const category of this.#featureOptions.categories) { // Validate that we should display this feature option category. This is useful when you want to only display feature option categories for certain device types. if(!this.#ui.validOptionCategory(currentDevice, category)) { continue; } const optionTable = document.createElement("table"); const thead = document.createElement("thead"); const tbody = document.createElement("tbody"); const trFirst = document.createElement("tr"); const th = document.createElement("th"); // Set our table options. optionTable.classList.add("table", "table-borderless", "table-sm", "table-hover"); th.classList.add("p-0"); th.style.fontWeight = "bold"; th.colSpan = 3; tbody.classList.add("border"); // Add the feature option category description. th.appendChild(document.createTextNode(category.description + (!currentDevice ? " (Global)" : (this.#ui.isController(currentDevice) ? " (Controller-specific)" : " (Device-specific)")))); // Add the table header to the row. trFirst.appendChild(th); // Add the table row to the table head. thead.appendChild(trFirst); // Finally, add the table head to the table. optionTable.appendChild(thead); // Keep track of the number of options we have made available in a given category. let optionsVisibleCount = 0; // Now enumerate all the feature options for a given device. for(const option of this.#featureOptions.options[category.name]) { // Only show feature options that are valid for this device. if(!this.#ui.validOption(currentDevice, option)) { continue; } // Expand the full feature option. const featureOption = this.#featureOptions.expandOption(category, option); // Create the next table row. const trX = document.createElement("tr"); trX.classList.add("align-top"); trX.id = "row-" + featureOption; // Create a checkbox for the option. const tdCheckbox = document.createElement("td"); // Create the actual checkbox for the option. const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.readOnly = false; checkbox.id = featureOption; checkbox.name = featureOption; checkbox.value = featureOption + (!currentDevice ? "" : ("." + currentDevice.serialNumber)); let initialValue = undefined; let initialScope; // Determine our initial option scope to show the user what's been set. switch(initialScope = this.#featureOptions.scope(featureOption, currentDevice?.serialNumber, this.#controller)) { case "global": case "controller": // If we're looking at the global scope, show the option value. Otherwise, we show that we're inheriting a value from the scope above. if(!currentDevice) { checkbox.checked = this.#featureOptions.test(featureOption); if(this.#featureOptions.isValue(featureOption)) { initialValue = this.#featureOptions.value(checkbox.id); } if(checkbox.checked) { checkbox.indeterminate = false; } } else { if(this.#featureOptions.isValue(featureOption)) { initialValue = this.#featureOptions.value(checkbox.id, (initialScope === "controller") ? this.#controller : undefined); } checkbox.readOnly = checkbox.indeterminate = true; } break; case "device": case "none": default: checkbox.checked = this.#featureOptions.test(featureOption, currentDevice?.serialNumber); if(this.#featureOptions.isValue(featureOption)) { initialValue = this.#featureOptions.value(checkbox.id, currentDevice?.serialNumber); } break; } checkbox.defaultChecked = option.default; checkbox.classList.add("mx-2"); // Add the checkbox to the table cell. tdCheckbox.appendChild(checkbox); // Add the checkbox to the table row. trX.appendChild(tdCheckbox); const tdLabel = document.createElement("td"); tdLabel.classList.add("w-100"); tdLabel.colSpan = 2; let inputValue = null; // Add an input field if we have a value-centric feature option. if(this.#featureOptions.isValue(featureOption)) { const tdInput = document.createElement("td"); tdInput.classList.add("mr-2"); tdInput.style.width = "10%"; inputValue = document.createElement("input"); inputValue.type = "text"; inputValue.value = initialValue ?? option.defaultValue; inputValue.size = 5; inputValue.readOnly = checkbox.readOnly; // Add or remove the setting from our configuration when we've changed our state. inputValue.addEventListener("change", () => checkbox.dispatchEvent(new Event("change"))); tdInput.appendChild(inputValue); trX.appendChild(tdInput); } // Create a label for the checkbox with our option description. const labelDescription = document.createElement("label"); labelDescription.for = checkbox.id; labelDescription.style.cursor = "pointer"; labelDescription.classList.add("user-select-none", "my-0", "py-0"); // Highlight options for the user that are different than our defaults. const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, this.#controller); if(scopeColor) { labelDescription.classList.add(scopeColor); } // Add or remove the setting from our configuration when we've changed our state. checkbox.addEventListener("change", async () => { // Find the option in our list and delete it if it exists. const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!currentDevice ? "" : ("\\." + currentDevice.serialNumber)) + "(?:\\.([^\\.]*))?$", "gi"); const newOptions = this.#featureOptions.configuredOptions.filter(entry => !optionRegex.test(entry)); // Figure out if we've got the option set upstream. let upstreamOption = false; // We explicitly want to check for the scope of the feature option above where we are now, so we can appropriately determine what we should show. switch(this.#featureOptions.scope(checkbox.id, (currentDevice && (currentDevice.serialNumber !== this.#controller)) ? this.#controller : undefined)) { case "device": case "controller": if(currentDevice.serialNumber !== this.#controller) { upstreamOption = true; } break; case "global": if(currentDevice) { upstreamOption = true; } break; default: break; } // We're currently in an indetermindate state and transitioning to an unchecked state. if(checkbox.readOnly) { // The user wants to change the state to unchecked. We need this because a checkbox can be in both an unchecked and indeterminate simultaneously, so we use // the readOnly property to let us know that we've just cycled from an indeterminate state. checkbox.checked = checkbox.readOnly = false; // If we have a value-centric feature option, we show the default value when we're in an indeterminate state. if(this.#featureOptions.isValue(featureOption)) { // If we're unchecked, clear out the value and make it read only. We show the system default for reference. inputValue.value = option.defaultValue; inputValue.readOnly = true; } } else if(!checkbox.checked) { // We're currently in a checked state and transitioning to an unchecked or an indeterminate state. // If we have an upstream option configured, we reveal a third state to show inheritance of that option and allow the user to select it. if(upstreamOption) { // We want to set the readOnly property as well, since it will survive a user interaction when they click the checkbox to clear out the // indeterminate state. This allows us to effectively cycle between three states. checkbox.readOnly = checkbox.indeterminate = true; } // If we're in an indeterminate state, we need to traverse the tree to get the upstream value we're inheriting. if(this.#featureOptions.isValue(featureOption)) { let newInputValue; // If our scope is global, let's fallback on the default value. // eslint-disable-next-line eqeqeq if((currentDevice?.serialNumber == null) && (this.#controller == null)) { newInputValue = option.defaultValue; } else if(currentDevice?.serialNumber !== this.#controller) { // We're at the device level - get the controller level value if it exists and fallback to the global value otherwise. newInputValue = this.#featureOptions.value(checkbox.id, this.#controller) ?? this.#featureOptions.value(checkbox.id); } else { // We're at the controller level - get the global value. newInputValue = this.#featureOptions.value(checkbox.id); } // Our fallback if there's no value defined within the scope hierarchy is the default value. inputValue.value = newInputValue ?? option.defaultValue; inputValue.readOnly = true; } } else if(checkbox.checked) { // We're currently in an unchecked state and transitioning to a checked state. checkbox.readOnly = checkbox.indeterminate = false; if(this.#featureOptions.isValue(featureOption)) { inputValue.readOnly = false; } } // The feature option is different from the default - highlight it for the user, accounting for the scope hierarchy, and add it to our configuration. We // provide a visual queue to the user, highlighting to indicate that a non-default option has been set. if(!checkbox.indeterminate && ((checkbox.checked !== option.default) || (this.#featureOptions.isValue(featureOption) && (inputValue.value.toString() !== option.defaultValue.toString())) || upstreamOption)) { labelDescription.classList.add("text-info"); newOptions.push((checkbox.checked ? "Enable." : "Disable.") + checkbox.value + (this.#featureOptions.isValue(featureOption) && checkbox.checked ? ("." + inputValue.value) : "")); } else { // We've reset to the defaults, remove our highlighting. labelDescription.classList.remove("text-info"); } // Update our configuration in Homebridge. this.currentConfig[0].options = newOptions; this.#featureOptions.configuredOptions = newOptions; await homebridge.updatePluginConfig(this.currentConfig); // If we've reset to defaults, make sure our color coding for scope is reflected. if((checkbox.checked === option.default) || checkbox.indeterminate) { const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, this.#controller); if(scopeColor) { labelDescription.classList.add(scopeColor); } } // Adjust visibility of other feature options that depend on us. if(this.#featureOptions.groups[checkbox.id]) { const entryVisibility = this.#featureOptions.test(featureOption, currentDevice?.serialNumber) ? "" : "none"; // Lookup each feature option setting and set the visibility accordingly. for(const entry of this.#featureOptions.groups[checkbox.id]) { document.getElementById("row-" + entry).style.display = entryVisibility; } } }); // Add the actual description for the option after the checkbox. labelDescription.appendChild(document.createTextNode(option.description)); // Add the label to the table cell. tdLabel.appendChild(labelDescription); // Provide a cell-wide target to click on options. tdLabel.addEventListener("click", () => checkbox.click()); // Add the label table cell to the table row. trX.appendChild(tdLabel); // Adjust the visibility of the feature option, if it's logically grouped. if((option.group !== undefined) && !this.#featureOptions.test(category.name + (option.group.length ? ("." + option.group) : ""), currentDevice?.serialNumber)) { trX.style.display = "none"; } else { // Increment the visible option count. optionsVisibleCount++; } // Add the table row to the table body. tbody.appendChild(trX); } // Add the table body to the table. optionTable.appendChild(tbody); // If we have no options visible in a given category, then hide the entire category. if(!optionsVisibleCount) { optionTable.style.display = "none"; } // Add the table to the page. this.#configTable.appendChild(optionTable); } homebridge.hideSpinner(); } // Our default device information panel handler. #showDeviceInfoPanel(device) { const deviceFirmware = document.getElementById("device_firmware") ?? {}; const deviceSerial = document.getElementById("device_serial") ?? {}; // No device specified, we must be in a global context. if(!device) { deviceFirmware.innerHTML = "N/A"; deviceSerial.innerHTML = "N/A"; return; } // Display our device details. deviceFirmware.innerHTML = device.firmwareVersion; deviceSerial.innerHTML = device.serialNumber; } // Default method for enumerating the device list in the sidebar. #showSidebarDevices() { // Show the devices list only if we have actual devices to show. if(!this.#devices?.length) { return; } // Create a row for this device category. const trCategory = document.createElement("tr"); // Disable any pointer events and hover activity. trCategory.style.pointerEvents = "none"; // Create the cell for our device category row. const tdCategory = document.createElement("td"); tdCategory.classList.add("m-0", "p-0", "pl-1", "w-100"); // Add the category name, with appropriate casing. tdCategory.appendChild(document.createTextNode(this.#sidebar.deviceLabel)); tdCategory.style.fontWeight = "bold"; // Add the cell to the table row. trCategory.appendChild(tdCategory); // Add the table row to the table. this.devicesTable.appendChild(trCategory); for(const device of this.#devices) { // Create a row for this device. const trDevice = document.createElement("tr"); trDevice.classList.add("m-0", "p-0"); // Create a cell for our device. const tdDevice = document.createElement("td"); tdDevice.classList.add("m-0", "p-0", "w-100"); const label = document.createElement("label"); label.name = device.serialNumber; label.appendChild(document.createTextNode(device.name ?? "Unknown")); label.style.cursor = "pointer"; label.classList.add("mx-2", "my-0", "p-0", "w-100"); label.addEventListener("click", () => this.showDeviceOptions(device.serialNumber)); // Add the device label to our cell. tdDevice.appendChild(label); // Add the cell to the table row. trDevice.appendChild(tdDevice); // Add the table row to the table. this.devicesTable.appendChild(trDevice); this.webUiDeviceList.push(label); } } // Default method for retrieving the device list from the Homebridge accessory cache. async #getHomebridgeDevices() { // Retrieve the full list of cached accessories. let devices = await homebridge.getCachedAccessories(); // Filter out only the components we're interested in. devices = devices.map(device => ({ firmwareRevision: (device.services.find(service => service.constructorName === "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "FirmwareRevision")?.value ?? ""), manufacturer: (device.services.find(service => service.constructorName === "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "Manufacturer")?.value ?? ""), model: (device.services.find(service => service.constructorName === "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "Model")?.value ?? ""), name: device.displayName, serialNumber: (device.services.find(service => service.constructorName === "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "SerialNumber")?.value ?? "") })); // Sort it for posterity. devices.sort((a, b) => { const aCase = (a.name ?? "").toLowerCase(); const bCase = (b.name ?? "").toLowerCase(); return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0); }); // Return the list. return devices; } }