homebridge-plugin-utils 1.26.1 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,834 +6,3485 @@
6
6
 
7
7
  import { FeatureOptions} from "./featureoptions.js";
8
8
 
9
+ /**
10
+ * @typedef {Object} Device
11
+ * @property {string} firmwareRevision - The firmware version of the device.
12
+ * @property {string} manufacturer - The manufacturer of the device.
13
+ * @property {string} model - The model identifier of the device.
14
+ * @property {string} name - The display name of the device.
15
+ * @property {string} serialNumber - The unique serial number of the device.
16
+ * @property {string} [sidebarGroup] - Optional grouping identifier for sidebar organization.
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} Controller
21
+ * @property {string} address - The network address of the controller.
22
+ * @property {string} serialNumber - The unique serial number of the controller.
23
+ * @property {string} name - The display name of the controller.
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} Category
28
+ * @property {string} name - The internal name of the category.
29
+ * @property {string} description - The user-friendly description of the category.
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} Option
34
+ * @property {string} name - The option name.
35
+ * @property {string} description - The user-friendly description.
36
+ * @property {boolean} default - The default state of the option.
37
+ * @property {*} [defaultValue] - The default value for value-centric options.
38
+ * @property {number} [inputSize] - The character width for input fields.
39
+ * @property {string} [group] - The parent option this option depends on.
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} FeatureOptionsConfig
44
+ * @property {Function} [getControllers] - Handler to retrieve available controllers.
45
+ * @property {Function} [getDevices] - Handler to retrieve devices for a controller.
46
+ * @property {Function} [infoPanel] - Handler to display device information.
47
+ * @property {Object} [sidebar] - Sidebar configuration options.
48
+ * @property {string} [sidebar.controllerLabel="Controllers"] - Label for the controllers section.
49
+ * @property {string} [sidebar.deviceLabel="Devices"] - Label for the devices section.
50
+ * @property {Function} [sidebar.showDevices] - Handler to display devices in the sidebar.
51
+ * @property {Object} [ui] - UI validation and display options.
52
+ * @property {number} [ui.controllerRetryEnableDelayMs=5000] - Interval before enabling a retry button when connecting to a controller.
53
+ * @property {Function} [ui.isController] - Validates if a device is a controller.
54
+ * @property {Function} [ui.validOption] - Validates if an option should display for a device.
55
+ * @property {Function} [ui.validOptionCategory] - Validates if a category should display for a device.
56
+ */
57
+
58
+ /**
59
+ * webUiFeatureOptions - Manages the feature options user interface for Homebridge plugins.
60
+ *
61
+ * This class provides a comprehensive UI for managing hierarchical feature options with support for global, controller-specific, and device-specific settings.
62
+ * It implements a three-state checkbox system (checked/unchecked/indeterminate) to show option inheritance and provides search, filtering, and bulk management
63
+ * capabilities.
64
+ *
65
+ * @example
66
+ * // Basic usage with default configuration. This creates a feature options UI that reads devices from Homebridge's accessory cache and displays them in a
67
+ * // simple device-only mode without controller hierarchy.
68
+ * const featureOptionsUI = new webUiFeatureOptions();
69
+ * await featureOptionsUI.show();
70
+ *
71
+ * @example
72
+ * // Advanced usage with controller hierarchy and custom device retrieval. This example shows how to configure the UI for a plugin that connects to network
73
+ * // controllers which manage multiple devices. The UI will display a three-level hierarchy: global options, controller-specific options, and device-specific
74
+ * // options.
75
+ * const featureOptionsUI = new webUiFeatureOptions({
76
+ * // Custom controller retrieval function. This should return an array of controller objects with address, serialNumber, and name properties.
77
+ * getControllers: async () => {
78
+ * const controllers = await myPlugin.discoverControllers();
79
+ * return controllers.map(c => ({
80
+ * address: c.ip,
81
+ * serialNumber: c.mac,
82
+ * name: c.hostname
83
+ * }));
84
+ * },
85
+ *
86
+ * // Custom device retrieval function. When a controller is provided, this should return devices from that controller. When null is provided, it might
87
+ * // return cached devices or an empty array depending on your plugin's architecture.
88
+ * getDevices: async (controller) => {
89
+ * if(!controller) {
90
+ * return [];
91
+ * }
92
+ *
93
+ * // Connect to the controller and retrieve its devices. The first device in the array must always be a representation of the controller itself,
94
+ * // which allows controller-specific options to be configured.
95
+ * const devices = await myPlugin.getDevicesFromController(controller.address);
96
+ * return devices;
97
+ * },
98
+ *
99
+ * // Custom information panel. This displays device-specific information in the UI's info panel when a device is selected.
100
+ * infoPanel: (device) => {
101
+ * if(!device) {
102
+ * return;
103
+ * }
104
+ *
105
+ * // Update the info panel with device-specific information. You can show any relevant details here like firmware version, model, status, etc.
106
+ * document.getElementById("device_firmware").textContent = device.firmwareRevision || "Unknown";
107
+ * document.getElementById("device_model").textContent = device.model || "Unknown";
108
+ * document.getElementById("device_status").textContent = device.isOnline ? "Online" : "Offline";
109
+ * },
110
+ *
111
+ * // Customize the sidebar labels. These labels appear as section headers in the navigation sidebar.
112
+ * sidebar: {
113
+ * controllerLabel: "UniFi Controllers",
114
+ * deviceLabel: "Protect Devices"
115
+ * },
116
+ *
117
+ * // UI validation functions. These control which options and categories are displayed for different device types.
118
+ * ui: {
119
+ * // Determine if a device is actually a controller. Controllers get different options than regular devices.
120
+ * isController: (device) => {
121
+ * return device?.type === "controller" || device?.isController === true;
122
+ * },
123
+ *
124
+ * // Validate if an option should be shown for a specific device. This allows hiding irrelevant options based on device capabilities.
125
+ * validOption: (device, option) => {
126
+ * // Don't show camera-specific options for non-camera devices. This keeps the options relevant to each device type.
127
+ * if(option.name.startsWith("Video.") && device?.type !== "camera") {
128
+ * return false;
129
+ * }
130
+ *
131
+ * // Don't show doorbell options for non-doorbell cameras. This provides fine-grained control over option visibility.
132
+ * if(option.name.startsWith("Doorbell.") && !device?.hasChime) {
133
+ * return false;
134
+ * }
135
+ *
136
+ * return true;
137
+ * },
138
+ *
139
+ * // Validate if a category should be shown for a specific device. This allows hiding entire categories that don't apply.
140
+ * validOptionCategory: (device, category) => {
141
+ * // Hide the "Motion Detection" category for devices without motion sensors. This keeps the UI focused and relevant.
142
+ * if(category.name === "Motion" && !device?.hasMotionSensor) {
143
+ * return false;
144
+ * }
145
+ *
146
+ * return true;
147
+ * }
148
+ * }
149
+ * });
150
+ *
151
+ * // Display the UI. The show method is async because it needs to load configuration data and potentially connect to controllers.
152
+ * await featureOptionsUI.show();
153
+ *
154
+ * // Clean up when done. This removes all event listeners and frees resources to prevent memory leaks.
155
+ * featureOptionsUI.cleanup();
156
+ */
9
157
  export class webUiFeatureOptions {
10
158
 
11
159
  // Table containing the currently displayed feature options.
12
160
  #configTable;
13
161
 
14
- // The current controller context.
162
+ // The current controller context representing the controller serial number when viewing controller or device options.
15
163
  #controller;
16
164
 
17
- // The current plugin configuration.
165
+ // Controllers sidebar container element that holds the global options link and controller navigation links.
166
+ #controllersContainer;
167
+
168
+ // The current plugin configuration array retrieved from Homebridge.
18
169
  currentConfig;
19
170
 
20
- // Table containing the details on the currently selected device.
21
- deviceStatsTable;
171
+ // Container element for device statistics display in the info panel.
172
+ #deviceStatsContainer;
22
173
 
23
- // Current list of devices from the Homebridge accessory cache.
174
+ // Current list of devices retrieved from either the Homebridge accessory cache or a network controller.
24
175
  #devices;
25
176
 
26
- // Table containing the list of devices.
27
- devicesTable;
177
+ // Container element for the list of devices in the sidebar navigation.
178
+ #devicesContainer;
28
179
 
29
- // Feature options instance.
180
+ // Map of registered event listeners for cleanup management. Keys are unique identifiers, values contain element references and handler details.
181
+ #eventListeners;
182
+
183
+ // Feature options instance that manages the option hierarchy and state logic.
30
184
  #featureOptions;
31
185
 
32
- // Get devices handler.
33
- #getDevices;
186
+ // Handler function for retrieving available controllers. Optional - when not provided, the UI operates in device-only mode.
187
+ #getControllers;
34
188
 
35
- // Enable the use of controllers.
36
- #hasControllers;
189
+ // Handler function for retrieving devices. Defaults to reading from Homebridge's accessory cache if not provided.
190
+ #getDevices;
37
191
 
38
- // Device information panel handler.
192
+ // Device information panel handler function for displaying device-specific details.
39
193
  #infoPanel;
40
194
 
41
- // Sidebar configuration parameters.
42
- #sidebar;
195
+ // The original set of feature options captured when the UI is first displayed. Used for reverting changes to the last saved state.
196
+ #initialFeatureOptions;
43
197
 
44
- // Options UI configuration parameters.
45
- #ui;
198
+ // Search panel container element that holds the search input, filters, and status bar.
199
+ #searchPanel;
46
200
 
47
- // Current list of controllers, for webUI elements.
48
- webUiControllerList;
201
+ // Sidebar configuration parameters with sensible defaults. Stores labels for controller and device sections.
202
+ #sidebar = {
49
203
 
50
- // Current list of devices on a given controller, for webUI elements.
51
- webUiDeviceList;
204
+ controllerLabel: "Controllers"
205
+ };
52
206
 
53
- /**
54
- * Display the feature option webUI. All webUI configuration settings are optional.
55
- *
56
- * getDevices - return an array of displays to be displayed.
57
- * hasControllers - true (default) if the plugin hierarchically has controllers and then devices managed by each controller, rather than just devices.
58
- * infoPanel - handler to display information in the device detail information panel.
59
- * ui - customize which options are displayed in the feature option webUI:
60
- * isController - validate whether a given device is a controller. Returns true or false.
61
- * validOption - validate whether an option is valid on a given device (or controller).
62
- * validCategory - validate whether a category of options is valid for a given device (or controller).
63
- * sidebar - customize the sidebar for the feature option webUI:
64
- * controllerLabel - label to use for the controllers category. Defaults to "Controllers".
65
- * deviceLabel - label to use for the devices category. Defaults to "Devices".
66
- * showDevices - handler for enumerating devices in the sidebar.
67
- */
68
- constructor(options = {}) {
207
+ // Theme color scheme that's currently in use in the Homebridge UI.
208
+ #themeColor = {
69
209
 
70
- // Defaults for the feature option webUI sidebar.
71
- this.#ui = {
210
+ background: "",
211
+ text: ""
212
+ };
72
213
 
73
- isController: () => false,
74
- validOption: () => true,
75
- validOptionCategory: () => true
76
- };
214
+ // Options UI configuration parameters with sensible defaults. Contains validation functions for controllers, options, and categories.
215
+ #ui = {
77
216
 
78
- // Defaults for the feature option webUI sidebar.
79
- this.#sidebar = {
217
+ controllerRetryEnableDelayMs: 5000,
218
+ isController: () => false,
219
+ validOption: () => true,
220
+ validOptionCategory: () => true
221
+ };
80
222
 
81
- controllerLabel: "Controllers",
82
- deviceLabel: "Devices",
83
- showDevices: this.#showSidebarDevices.bind(this)
84
- };
223
+ /**
224
+ * Initialize the feature options webUI with customizable configuration.
225
+ *
226
+ * The webUI supports two modes: controller-based (devices grouped under controllers) and direct device mode (devices without controller hierarchy). All
227
+ * configuration options are optional and will use sensible defaults if not provided.
228
+ *
229
+ * @param {FeatureOptionsConfig} options - Configuration options for the webUI.
230
+ */
231
+ constructor(options = {}) {
85
232
 
86
- // Defaults for the feature option webUI.
233
+ // Extract options with defaults. We destructure here to get clean references to each configuration option while providing fallbacks.
87
234
  const {
88
235
 
89
- getDevices = this.#getHomebridgeDevices,
90
- hasControllers = true,
236
+ getControllers = undefined,
237
+ getDevices = this.getHomebridgeDevices,
91
238
  infoPanel = this.#showDeviceInfoPanel,
92
239
  sidebar = {},
93
240
  ui = {}
94
241
  } = options;
95
242
 
243
+ // Initialize all our properties. We cache DOM elements for performance and maintain state for the current controller and device context.
96
244
  this.#configTable = document.getElementById("configTable");
97
245
  this.#controller = null;
246
+ this.#controllersContainer = document.getElementById("controllersContainer");
98
247
  this.currentConfig = [];
99
- this.deviceStatsTable = document.getElementById("deviceStatsTable");
248
+ this.#deviceStatsContainer = document.getElementById("deviceStatsContainer");
100
249
  this.#devices = [];
101
- this.devicesTable = document.getElementById("devicesTable");
250
+ this.#devicesContainer = document.getElementById("devicesContainer");
251
+ this.#eventListeners = new Map();
102
252
  this.#featureOptions = null;
253
+ this.#getControllers = getControllers;
103
254
  this.#getDevices = getDevices;
104
- this.#hasControllers = hasControllers;
105
255
  this.#infoPanel = infoPanel;
106
- this.#sidebar = Object.assign({}, this.#sidebar, sidebar);
107
- this.#ui = Object.assign({}, this.#ui, ui);
108
- this.webUiControllerList = [];
109
- this.webUiDeviceList = [];
256
+ this.#searchPanel = document.getElementById("search");
257
+
258
+ // Merge the provided options with our defaults. This allows partial configuration while maintaining our sensible defaults.
259
+ Object.assign(this.#sidebar, sidebar);
260
+ Object.assign(this.#ui, ui);
110
261
  }
111
262
 
112
263
  /**
113
- * Render the feature options webUI.
264
+ * Register an event listener for later cleanup.
265
+ *
266
+ * 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
267
+ * occur from accumulating event listeners over time.
268
+ *
269
+ * @param {EventTarget} element - The DOM element to attach the listener to.
270
+ * @param {string} event - The event type to listen for.
271
+ * @param {EventListener} handler - The event handler function.
272
+ * @param {AddEventListenerOptions} [options] - Optional event listener options.
273
+ * @returns {string} A unique key that can be used to remove this specific listener.
274
+ * @private
114
275
  */
115
- async show() {
116
-
117
- // Show the beachball while we setup.
118
- homebridge.showSpinner();
119
- homebridge.hideSchemaForm();
276
+ #addEventListener(element, event, handler, options) {
120
277
 
121
- // Create our custom UI.
122
- document.getElementById("menuHome").classList.remove("btn-elegant");
123
- document.getElementById("menuHome").classList.add("btn-primary");
124
- document.getElementById("menuFeatureOptions").classList.add("btn-elegant");
125
- document.getElementById("menuFeatureOptions").classList.remove("btn-primary");
126
- document.getElementById("menuSettings").classList.remove("btn-elegant");
127
- document.getElementById("menuSettings").classList.add("btn-primary");
128
-
129
- // Hide the legacy UI.
130
- document.getElementById("pageSupport").style.display = "none";
131
- document.getElementById("pageFeatureOptions").style.display = "block";
278
+ // Add the event listener to the element.
279
+ element.addEventListener(event, handler, options);
132
280
 
133
- // Make sure we have the refreshed configuration.
134
- this.currentConfig = await homebridge.getPluginConfig();
281
+ // Store the listener information for cleanup. We generate a unique key using timestamp and random number to ensure uniqueness.
282
+ const key = "element-" + Date.now() + "-" + Math.random();
135
283
 
136
- // Retrieve the set of feature options available to us.
137
- const features = (await homebridge.request("/getOptions")) ?? [];
284
+ this.#eventListeners.set(key, { element, event, handler, options });
138
285
 
139
- // Initialize our feature option configuration.
140
- this.#featureOptions = new FeatureOptions(features.categories, features.options, this.currentConfig[0].options ?? []);
286
+ return key;
287
+ }
141
288
 
142
- // 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.
289
+ /**
290
+ * Remove a specific event listener by its key.
291
+ *
292
+ * This allows targeted removal of individual event listeners when needed, such as when removing temporary confirmation handlers.
293
+ *
294
+ * @param {string} key - The unique key returned by #addEventListener.
295
+ * @private
296
+ */
297
+ #removeEventListener(key) {
143
298
 
144
- // Retrieve the table for the our list of controllers and global options.
145
- const controllersTable = document.getElementById("controllersTable");
299
+ const listener = this.#eventListeners.get(key);
146
300
 
147
- // Start with a clean slate.
148
- controllersTable.innerHTML = "";
149
- this.devicesTable.innerHTML = "";
150
- this.#configTable.innerHTML = "";
151
- this.webUiDeviceList = [];
301
+ if(listener) {
152
302
 
153
- // Create our override styles for things like hover for our sidebar and workarounds for Homebridge UI quirks.
154
- const overrideStyles = document.createElement("style");
303
+ listener.element.removeEventListener(listener.event, listener.handler, listener.options);
304
+ this.#eventListeners.delete(key);
305
+ }
306
+ }
155
307
 
156
- // We want to override the default colors that Homebridge UI might apply for table cells.
157
- overrideStyles.innerHTML = "td { color: unset !important }";
308
+ /**
309
+ * Clean up all registered event listeners.
310
+ *
311
+ * This prevents memory leaks when switching views or updating the UI. We iterate through all stored listeners and remove them from their elements before
312
+ * clearing our tracking map.
313
+ *
314
+ * @private
315
+ */
316
+ #cleanupEventListeners() {
158
317
 
159
- // We emulate the styles that Bootstrap uses when hovering over a table, accounting for both light and dark modes.
160
- overrideStyles.innerHTML += "@media (prefers-color-scheme: dark) { .hbpu-hover td:hover { background-color: #212121; color: #FFA000 !important } }" +
161
- "@media (prefers-color-scheme: light) { .hbpu-hover td:hover { background-color: #ECECEC; } }";
318
+ // Remove all stored event listeners. We use for...of to iterate through the values since we don't need the keys here.
319
+ for(const listener of this.#eventListeners.values()) {
162
320
 
163
- document.head.appendChild(overrideStyles);
321
+ listener.element.removeEventListener(listener.event, listener.handler, listener.options);
322
+ }
164
323
 
165
- // Add our hover styles to the controllers and devices tables.
166
- controllersTable.classList.add("hbpu-hover");
167
- this.devicesTable.classList.add("hbpu-hover");
324
+ // Clear the map to release all references.
325
+ this.#eventListeners.clear();
326
+ }
168
327
 
169
- // Hide the UI until we're ready.
170
- document.getElementById("sidebar").style.display = "none";
171
- document.getElementById("headerInfo").style.display = "none";
172
- document.getElementById("deviceStatsTable").style.display = "none";
328
+ /**
329
+ * Initialize event delegation handlers for the entire feature options interface.
330
+ *
331
+ * This sets up all our event delegation handlers on parent containers. By using event delegation, we can handle events for dynamically created elements
332
+ * without attaching individual listeners. This improves performance and memory usage while simplifying our event management.
333
+ *
334
+ * @private
335
+ */
336
+ #initializeEventDelegation() {
173
337
 
174
- // If we haven't configured any controllers, we're done.
175
- if(this.#hasControllers && !this.currentConfig[0]?.controllers?.length) {
338
+ // Get the main feature options container for event delegation.
339
+ const featureOptionsPage = document.getElementById("pageFeatureOptions");
176
340
 
177
- document.getElementById("headerInfo").innerHTML = "Please configure a controller to access in the main settings tab before configuring feature options.";
178
- document.getElementById("headerInfo").style.display = "";
179
- homebridge.hideSpinner();
341
+ if(!featureOptionsPage) {
180
342
 
343
+ // If we can't find the container, we're likely not in the right context yet.
181
344
  return;
182
345
  }
183
346
 
184
- // Initialize our informational header.
185
- document.getElementById("headerInfo").style.fontWeight = "bold";
186
- document.getElementById("headerInfo").innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:" +
187
- "<br><i class=\"text-warning\">Global options</i> (lowest priority) &rarr; " +
188
- (this.#hasControllers ? "<i class=\"text-success\">Controller options</i> &rarr; " : "") +
189
- "<i class=\"text-info\">Device options</i> (highest priority)";
347
+ // Handle all sidebar navigation clicks through delegation. This covers global options, controller links, and device links.
348
+ this.#addEventListener(featureOptionsPage, "click", async (event) => {
190
349
 
191
- // Enumerate our global options.
192
- const trGlobal = document.createElement("tr");
350
+ // Check for sidebar navigation links.
351
+ const navLink = event.target.closest(".nav-link[data-navigation]");
193
352
 
194
- // Create the cell for our global options.
195
- const tdGlobal = document.createElement("td");
353
+ if(navLink) {
196
354
 
197
- tdGlobal.classList.add("m-0", "p-0", "w-100");
355
+ event.preventDefault();
198
356
 
199
- // Create our label target.
200
- const globalLabel = document.createElement("label");
357
+ const navigationType = navLink.getAttribute("data-navigation");
201
358
 
202
- globalLabel.name = "Global Options";
203
- globalLabel.appendChild(document.createTextNode("Global Options"));
204
- globalLabel.style.cursor = "pointer";
205
- globalLabel.classList.add("m-0", "p-0", "pl-1", "w-100");
359
+ // Handle different navigation types.
360
+ switch(navigationType) {
206
361
 
207
- globalLabel.addEventListener("click", () => this.#showSidebar(null));
362
+ case "global":
208
363
 
209
- // Add the global options label.
210
- tdGlobal.appendChild(globalLabel);
211
- tdGlobal.style.fontWeight = "bold";
364
+ this.#showGlobalOptions();
212
365
 
213
- // Add the global cell to the table.
214
- trGlobal.appendChild(tdGlobal);
366
+ break;
215
367
 
216
- // Now add it to the overall controllers table.
217
- controllersTable.appendChild(trGlobal);
368
+ case "controller":
218
369
 
219
- // Add it as another controller of device, for UI purposes.
220
- (this.#hasControllers ? this.webUiControllerList : this.webUiDeviceList).push(globalLabel);
370
+ await this.#showControllerOptions(navLink.getAttribute("data-device-serial"));
221
371
 
222
- if(this.#hasControllers) {
372
+ break;
223
373
 
224
- // Create a row for our controllers.
225
- const trController = document.createElement("tr");
374
+ case "device":
226
375
 
227
- // Disable any pointer events and hover activity.
228
- trController.style.pointerEvents = "none";
376
+ this.#showDeviceOptions(navLink.name);
229
377
 
230
- // Create the cell for our controller category row.
231
- const tdController = document.createElement("td");
378
+ break;
232
379
 
233
- tdController.classList.add("m-0", "p-0", "pl-1", "w-100");
380
+ default:
234
381
 
235
- // Add the category name, with appropriate casing.
236
- tdController.appendChild(document.createTextNode(this.#sidebar.controllerLabel));
237
- tdController.style.fontWeight = "bold";
382
+ break;
383
+ }
238
384
 
239
- // Add the cell to the table row.
240
- trController.appendChild(tdController);
385
+ return;
386
+ }
241
387
 
242
- // Add the table row to the table.
243
- controllersTable.appendChild(trController);
388
+ // Check for filter buttons.
389
+ const filterButton = event.target.closest(".btn[data-filter]");
244
390
 
245
- for(const controller of this.currentConfig[0].controllers) {
391
+ if(filterButton) {
246
392
 
247
- // Create a row for this controller.
248
- const trDevice = document.createElement("tr");
393
+ // Determine the class based on the filter type. We start with our default of btn-primary.
394
+ let filterClass = "btn-primary";
249
395
 
250
- trDevice.classList.add("m-0", "p-0");
396
+ if(filterButton.getAttribute("data-filter") === "modified") {
251
397
 
252
- // Create a cell for our controller.
253
- const tdDevice = document.createElement("td");
398
+ filterClass = "btn-warning text-dark";
399
+ }
254
400
 
255
- tdDevice.classList.add("m-0", "p-0", "w-100");
401
+ // Create our parameters to pass along to the click handler.
402
+ const filterConfig = {
256
403
 
257
- const label = document.createElement("label");
404
+ class: filterClass,
405
+ filter: filterButton.getAttribute("data-filter-type") ?? filterButton.getAttribute("data-filter"),
406
+ text: filterButton.textContent
407
+ };
258
408
 
259
- label.name = controller.address;
260
- label.appendChild(document.createTextNode(controller.address));
261
- label.style.cursor = "pointer";
262
- label.classList.add("mx-2", "my-0", "p-0", "w-100");
409
+ this.#handleFilterClick(filterButton, filterConfig);
263
410
 
264
- label.addEventListener("click", () => this.#showSidebar(controller));
411
+ return;
412
+ }
265
413
 
266
- // Add the controller label to our cell.
267
- tdDevice.appendChild(label);
414
+ // Handle expanding and collapsing all feature option categories when toggled.
415
+ const toggleButton = event.target.closest("#toggleAllCategories");
268
416
 
269
- // Add the cell to the table row.
270
- trDevice.appendChild(tdDevice);
417
+ if(toggleButton) {
271
418
 
272
- // Add the table row to the table.
273
- controllersTable.appendChild(trDevice);
419
+ this.#handleToggleClick(toggleButton);
274
420
 
275
- this.webUiControllerList.push(label);
421
+ return;
276
422
  }
277
- }
278
423
 
279
- // All done. Let the user interact with us.
280
- homebridge.hideSpinner();
281
-
282
- // Default the user on our global settings if we have no controller.
283
- this.#showSidebar(this.#hasControllers ? this.currentConfig[0].controllers[0] : null);
284
- }
424
+ // Handle any button with a reset-related action.
425
+ const resetButton = event.target.closest(".btn[data-action^='reset']");
285
426
 
286
- // Show the device list taking the controller context into account.
287
- async #showSidebar(controller) {
427
+ if(resetButton) {
288
428
 
289
- // Show the beachball while we setup.
290
- homebridge.showSpinner();
429
+ const action = resetButton.getAttribute("data-action");
430
+ const resetDefaultsBtn = resetButton.parentElement.querySelector("button[data-action='reset-defaults']");
431
+ const resetRevertBtn = resetButton.parentElement.querySelector("button[data-action='reset-revert']");
291
432
 
292
- // Grab the list of devices we're displaying.
293
- this.#devices = await this.#getDevices(controller);
433
+ switch(action) {
294
434
 
295
- if(this.#hasControllers) {
435
+ case "reset-toggle":
296
436
 
297
- // Make sure we highlight the selected controller so the user knows where we are.
298
- this.webUiControllerList.map(webUiEntry => (webUiEntry.name === (controller ? controller.address : "Global Options")) ?
299
- webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white"));
437
+ resetDefaultsBtn?.classList.toggle("d-none");
438
+ resetRevertBtn?.classList.toggle("d-none");
439
+ resetButton.textContent = resetDefaultsBtn?.classList.contains("d-none") ? "Reset..." : "\u25B6";
300
440
 
301
- // Unable to connect to the controller for some reason.
302
- if(controller && !this.#devices?.length) {
441
+ break;
303
442
 
304
- this.devicesTable.innerHTML = "";
305
- this.#configTable.innerHTML = "";
443
+ case "reset-defaults":
444
+ case "reset-revert":
306
445
 
307
- document.getElementById("headerInfo").innerHTML = ["Unable to connect to the controller.",
308
- "Check the Settings tab to verify the controller details are correct.",
309
- "<code class=\"text-danger\">" + (await homebridge.request("/getErrorMessage")) + "</code>"].join("<br>");
310
- document.getElementById("headerInfo").style.display = "";
311
- this.deviceStatsTable.style.display = "none";
446
+ if(action === "reset-defaults") {
312
447
 
313
- homebridge.hideSpinner();
448
+ await this.#resetAllOptions();
449
+ } else {
314
450
 
315
- return;
316
- }
451
+ await this.#revertToInitialOptions();
452
+ }
317
453
 
318
- // The first entry returned by getDevices() must always be the controller.
319
- this.#controller = this.#devices[0]?.serialNumber ?? null;
320
- }
454
+ resetButton.classList.toggle("d-none");
455
+ resetRevertBtn?.classList.toggle("d-none");
321
456
 
322
- // Make the UI visible.
323
- document.getElementById("headerInfo").style.display = "";
324
- document.getElementById("sidebar").style.display = "";
457
+ break;
325
458
 
326
- // Wipe out the device list, except for our global entry.
327
- this.webUiDeviceList.splice(1, this.webUiDeviceList.length);
459
+ default:
328
460
 
329
- // Start with a clean slate.
330
- this.devicesTable.innerHTML = "";
461
+ break;
462
+ }
331
463
 
332
- // Populate our devices sidebar.
333
- this.#sidebar.showDevices(controller, this.#devices);
464
+ return;
465
+ }
334
466
 
335
- // Display the feature options to the user.
336
- this.showDeviceOptions(controller ? this.#devices[0].serialNumber : "Global Options");
467
+ // Handle expanding or collapsing a feature option category when its header is clicked.
468
+ const headerCell = event.target.closest("table[data-category] thead th");
337
469
 
338
- // All done. Let the user interact with us.
339
- homebridge.hideSpinner();
340
- }
470
+ if(headerCell) {
341
471
 
342
- // Show feature option information for a specific device, controller, or globally.
343
- showDeviceOptions(deviceId) {
472
+ const table = headerCell.closest("table");
473
+ const tbody = table.querySelector("tbody");
344
474
 
345
- homebridge.showSpinner();
475
+ if(tbody) {
346
476
 
347
- // Update the selected device for visibility.
348
- this.webUiDeviceList.map(webUiEntry => (webUiEntry.name === deviceId) ?
349
- webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white"));
477
+ const isCollapsed = tbody.style.display === "none";
350
478
 
351
- // Populate the device information info pane.
352
- const currentDevice = this.#devices.find(device => device.serialNumber === deviceId);
479
+ tbody.style.display = isCollapsed ? "" : "none";
353
480
 
354
- // Populate the details view. If there's no device specified, the context is considered global and we hide the device details view.
355
- if(!currentDevice) {
481
+ const arrow = table.querySelector(".arrow");
356
482
 
357
- this.deviceStatsTable.style.display = "none";
358
- }
483
+ if(arrow) {
359
484
 
360
- this.#infoPanel(currentDevice);
485
+ arrow.textContent = isCollapsed ? "\u25BC " : "\u25B6 ";
486
+ }
361
487
 
362
- if(currentDevice) {
488
+ // Update accessibility state to reflect the current expansion state for assistive technologies.
489
+ headerCell.setAttribute("aria-expanded", isCollapsed ? "true" : "false");
363
490
 
364
- this.deviceStatsTable.style.display = "";
365
- }
491
+ document.getElementById("toggleAllCategories")?.updateState?.();
492
+ }
366
493
 
367
- // Start with a clean slate.
368
- this.#configTable.innerHTML = "";
494
+ return;
495
+ }
369
496
 
370
- for(const category of this.#featureOptions.categories) {
497
+ // Check for option labels (but not if clicking on inputs). Clicking a label toggles the associated checkbox for better UX.
498
+ const labelCell = event.target.closest("td.option-label");
371
499
 
372
- // 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.
373
- if(!this.#ui.validOptionCategory(currentDevice, category)) {
500
+ if(labelCell && !event.target.closest("input")) {
374
501
 
375
- continue;
502
+ labelCell.closest("tr").querySelector("input[type='checkbox']")?.click();
376
503
  }
504
+ });
377
505
 
378
- const optionTable = document.createElement("table");
379
- const thead = document.createElement("thead");
380
- const tbody = document.createElement("tbody");
381
- const trFirst = document.createElement("tr");
382
- const th = document.createElement("th");
383
-
384
- // Set our table options.
385
- optionTable.classList.add("table", "table-borderless", "table-sm", "table-hover");
386
- th.classList.add("p-0");
387
- th.style.fontWeight = "bold";
388
- th.colSpan = 3;
389
- tbody.classList.add("border");
506
+ // Handle checkbox changes through delegation.
507
+ this.#addEventListener(featureOptionsPage, "change", (event) => {
390
508
 
391
- // Add the feature option category description.
392
- th.appendChild(document.createTextNode(category.description + (!currentDevice ? " (Global)" :
393
- (this.#ui.isController(currentDevice) ? " (Controller-specific)" : " (Device-specific)"))));
509
+ // Check for option checkboxes.
510
+ if(event.target.matches("input[type='checkbox']")) {
394
511
 
395
- // Add the table header to the row.
396
- trFirst.appendChild(th);
512
+ const checkbox = event.target;
513
+ const optionName = checkbox.id;
514
+ const deviceSerial = checkbox.getAttribute("data-device-serial");
397
515
 
398
- // Add the table row to the table head.
399
- thead.appendChild(trFirst);
516
+ // Find the option in the feature options.
517
+ const categoryName = event.target.closest("table[data-category]")?.getAttribute("data-category");
400
518
 
401
- // Finally, add the table head to the table.
402
- optionTable.appendChild(thead);
519
+ if(!categoryName) {
403
520
 
404
- // Keep track of the number of options we have made available in a given category.
405
- let optionsVisibleCount = 0;
521
+ return;
522
+ }
406
523
 
407
- // Now enumerate all the feature options for a given device.
408
- for(const option of this.#featureOptions.options[category.name]) {
524
+ const option = this.#featureOptions.options[categoryName]?.find(opt => this.#featureOptions.expandOption(categoryName, opt) === optionName);
409
525
 
410
- // Only show feature options that are valid for this device.
411
- if(!this.#ui.validOption(currentDevice, option)) {
526
+ if(!option) {
412
527
 
413
- continue;
528
+ return;
414
529
  }
415
530
 
416
- // Expand the full feature option.
417
- const featureOption = this.#featureOptions.expandOption(category, option);
531
+ if(option) {
532
+
533
+ const device = deviceSerial ? this.#devices.find(device => device.serialNumber === deviceSerial) : null;
534
+ const label = checkbox.closest("tr").querySelector(".option-label label");
535
+ const inputValue = checkbox.closest("tr").querySelector("input[type='text']");
418
536
 
419
- // Create the next table row.
420
- const trX = document.createElement("tr");
537
+ this.#handleOptionChange(checkbox, optionName, option, device, label, inputValue);
538
+ }
421
539
 
422
- trX.classList.add("align-top");
423
- trX.id = "row-" + featureOption;
540
+ return;
541
+ }
424
542
 
425
- // Create a checkbox for the option.
426
- const tdCheckbox = document.createElement("td");
543
+ // Check for value inputs. When a text input changes, we trigger the checkbox change event to update the configuration.
544
+ if(event.target.matches("input[type='text']")) {
427
545
 
428
- // Create the actual checkbox for the option.
429
- const checkbox = document.createElement("input");
546
+ event.target.closest("tr").querySelector("input[type='checkbox']")?.dispatchEvent(new Event("change", { bubbles: true }));
430
547
 
431
- checkbox.type = "checkbox";
432
- checkbox.readOnly = false;
433
- checkbox.id = featureOption;
434
- checkbox.name = featureOption;
435
- checkbox.value = featureOption + (!currentDevice ? "" : ("." + currentDevice.serialNumber));
548
+ return;
549
+ }
550
+ });
436
551
 
437
- let initialValue = undefined;
438
- let initialScope;
552
+ // Handle search input through delegation with debouncing for performance.
553
+ this.#addEventListener(featureOptionsPage, "input", (event) => {
439
554
 
440
- // Determine our initial option scope to show the user what's been set.
441
- switch(initialScope = this.#featureOptions.scope(featureOption, currentDevice?.serialNumber, this.#controller)) {
555
+ if(event.target.matches("#searchInput")) {
442
556
 
443
- case "global":
444
- case "controller":
557
+ const searchInput = event.target;
445
558
 
446
- // 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.
447
- if(!currentDevice) {
559
+ if(!searchInput._searchTimeout) {
448
560
 
449
- checkbox.checked = this.#featureOptions.test(featureOption);
561
+ searchInput._searchTimeout = null;
562
+ }
450
563
 
451
- if(this.#featureOptions.isValue(featureOption)) {
564
+ clearTimeout(searchInput._searchTimeout);
452
565
 
453
- initialValue = this.#featureOptions.value(checkbox.id);
454
- }
566
+ searchInput._searchTimeout = setTimeout(() => {
455
567
 
456
- if(checkbox.checked) {
568
+ this.#handleSearch(searchInput.value.trim(),
569
+ [...document.querySelectorAll("#configTable tbody tr")],
570
+ [...document.querySelectorAll("#configTable table")],
571
+ searchInput._originalVisibility || new Map()
572
+ );
573
+ }, 300);
574
+ }
575
+ });
457
576
 
458
- checkbox.indeterminate = false;
459
- }
577
+ // Handle keyboard events for search shortcuts and navigation.
578
+ this.#addEventListener(featureOptionsPage, "keydown", (event) => {
460
579
 
461
- } else {
580
+ // Handle escape key in search input to clear the search.
581
+ if(event.target.matches("#searchInput") && (event.key === "Escape")) {
462
582
 
463
- if(this.#featureOptions.isValue(featureOption)) {
583
+ event.target.value = "";
584
+ event.target.dispatchEvent(new Event("input", { bubbles: true }));
585
+ }
464
586
 
465
- initialValue = this.#featureOptions.value(checkbox.id, (initialScope === "controller") ? this.#controller : undefined);
466
- }
587
+ // Ctrl/Cmd+F to focus search when the search panel is visible.
588
+ if((event.ctrlKey || event.metaKey) && (event.key === "f")) {
467
589
 
468
- checkbox.readOnly = checkbox.indeterminate = true;
469
- }
590
+ const searchInput = document.getElementById("searchInput");
470
591
 
471
- break;
592
+ if(searchInput && this.#searchPanel && (this.#searchPanel.style.display !== "none")) {
472
593
 
473
- case "device":
474
- case "none":
475
- default:
594
+ event.preventDefault();
595
+ searchInput.focus();
596
+ searchInput.select();
597
+ }
598
+ }
476
599
 
477
- checkbox.checked = this.#featureOptions.test(featureOption, currentDevice?.serialNumber);
600
+ // Allow expanding/collapsing categories via keyboard. We support Enter and Space as expected.
601
+ if(event.target.matches("table[data-category] thead th") && ((event.key === "Enter") || (event.key === " "))) {
478
602
 
479
- if(this.#featureOptions.isValue(featureOption)) {
603
+ event.preventDefault();
480
604
 
481
- initialValue = this.#featureOptions.value(checkbox.id, currentDevice?.serialNumber);
482
- }
605
+ const headerCell = event.target.closest("table[data-category] thead th");
483
606
 
484
- break;
607
+ if(headerCell) {
608
+
609
+ headerCell.click();
485
610
  }
611
+ }
612
+ });
613
+ }
486
614
 
487
- checkbox.defaultChecked = option.default;
488
- checkbox.classList.add("mx-2");
615
+ /**
616
+ * Create a DOM element with optional properties and children.
617
+ *
618
+ * This helper reduces the verbosity of DOM manipulation throughout the code. It handles common patterns like setting classes, styles, and adding children
619
+ * in a more functional style.
620
+ *
621
+ * @param {string} tag - The HTML tag name to create.
622
+ * @param {Object} [props={}] - Properties to set on the element.
623
+ * @param {string|string[]|Array} [props.classList] - CSS classes to add.
624
+ * @param {Object} [props.style] - Inline styles to apply.
625
+ * @param {Array<string|Node>} [children=[]] - Child nodes or text content.
626
+ * @returns {HTMLElement} The created DOM element.
627
+ * @private
628
+ */
629
+ #createElement(tag, props = {}, children = []) {
489
630
 
490
- // Add the checkbox to the table cell.
491
- tdCheckbox.appendChild(checkbox);
631
+ const element = document.createElement(tag);
492
632
 
493
- // Add the checkbox to the table row.
494
- trX.appendChild(tdCheckbox);
633
+ // Apply any CSS classes. We handle both single classes and arrays, making the API flexible for callers.
634
+ if(props.classList) {
495
635
 
496
- const tdLabel = document.createElement("td");
636
+ const classes = Array.isArray(props.classList) ? props.classList : props.classList.split(" ");
497
637
 
498
- tdLabel.classList.add("w-100");
499
- tdLabel.colSpan = 2;
638
+ element.classList.add(...classes);
639
+ delete props.classList;
640
+ }
500
641
 
501
- let inputValue = null;
642
+ // Apply any inline styles. We use Object.assign for efficiency when setting multiple style properties at once.
643
+ if(props.style) {
502
644
 
503
- // Add an input field if we have a value-centric feature option.
504
- if(this.#featureOptions.isValue(featureOption)) {
645
+ Object.assign(element.style, props.style);
646
+ delete props.style;
647
+ }
505
648
 
506
- const tdInput = document.createElement("td");
649
+ // Apply all other properties. This handles standard DOM properties like id, name, type, etc.
650
+ for(const [ key, value ] of Object.entries(props)) {
507
651
 
508
- tdInput.classList.add("mr-2");
509
- tdInput.style.width = "10%";
652
+ // Data attributes and other hyphenated attributes need setAttribute.
653
+ if(key.includes("-")) {
510
654
 
511
- inputValue = document.createElement("input");
512
- inputValue.type = "text";
513
- inputValue.value = initialValue ?? option.defaultValue;
514
- inputValue.size = 5;
515
- inputValue.readOnly = checkbox.readOnly;
655
+ element.setAttribute(key, value);
656
+ } else {
516
657
 
517
- // Add or remove the setting from our configuration when we've changed our state.
518
- inputValue.addEventListener("change", () => checkbox.dispatchEvent(new Event("change")));
519
- tdInput.appendChild(inputValue);
520
- trX.appendChild(tdInput);
521
- }
658
+ element[key] = value;
659
+ }
660
+ }
522
661
 
523
- // Create a label for the checkbox with our option description.
524
- const labelDescription = document.createElement("label");
662
+ // Add any children, handling both elements and text nodes. Text strings are automatically converted to text nodes for proper DOM insertion.
663
+ for(const child of children) {
525
664
 
526
- labelDescription.for = checkbox.id;
527
- labelDescription.style.cursor = "pointer";
528
- labelDescription.classList.add("user-select-none", "my-0", "py-0");
665
+ element.appendChild((typeof child === "string") ? document.createTextNode(child) : child);
666
+ }
529
667
 
530
- // Highlight options for the user that are different than our defaults.
531
- const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, this.#controller);
668
+ return element;
669
+ }
532
670
 
533
- if(scopeColor) {
671
+ /**
672
+ * Toggle CSS classes on an element more elegantly.
673
+ *
674
+ * This utility helps manage the common pattern of adding and removing classes based on state changes, particularly useful for highlighting selected items.
675
+ *
676
+ * @param {HTMLElement} element - The element to modify.
677
+ * @param {string[]} [add=[]] - Classes to add.
678
+ * @param {string[]} [remove=[]] - Classes to remove.
679
+ * @private
680
+ */
681
+ #toggleClasses(element, add = [], remove = []) {
534
682
 
535
- labelDescription.classList.add(scopeColor);
536
- }
683
+ for(const cls of remove) {
537
684
 
538
- // Add or remove the setting from our configuration when we've changed our state.
539
- checkbox.addEventListener("change", async () => {
685
+ element.classList.remove(cls);
686
+ }
540
687
 
541
- // Find the option in our list and delete it if it exists.
542
- const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!currentDevice ? "" : ("\\." + currentDevice.serialNumber)) +
543
- "(?:\\.([^\\.]*))?$", "gi");
688
+ for(const cls of add) {
544
689
 
545
- const newOptions = this.#featureOptions.configuredOptions.filter(entry => !optionRegex.test(entry));
690
+ element.classList.add(cls);
691
+ }
692
+ }
546
693
 
547
- // Figure out if we've got the option set upstream.
548
- let upstreamOption = false;
694
+ /**
695
+ * Hide the feature options webUI and clean up all resources.
696
+ *
697
+ * 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
698
+ * feature options view or when the plugin configuration UI is being destroyed.
699
+ *
700
+ * @returns {Promise<void>}
701
+ * @public
702
+ */
703
+ hide() {
549
704
 
550
- // 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.
551
- switch(this.#featureOptions.scope(checkbox.id, (currentDevice && (currentDevice.serialNumber !== this.#controller)) ? this.#controller : undefined)) {
705
+ // Hide the UI elements until we're ready to show them. This prevents visual flickering as we build the interface.
706
+ for(const id of [ "deviceStatsContainer", "headerInfo", "optionsContainer", "search", "sidebar" ]) {
552
707
 
553
- case "device":
554
- case "controller":
708
+ const element = document.getElementById(id);
555
709
 
556
- if(currentDevice.serialNumber !== this.#controller) {
710
+ if(element) {
557
711
 
558
- upstreamOption = true;
559
- }
712
+ element.style.display = "none";
713
+ }
714
+ }
560
715
 
561
- break;
716
+ this.cleanup();
717
+ }
562
718
 
563
- case "global":
719
+ /**
720
+ * Show global options in the main content area.
721
+ *
722
+ * This displays the feature options that apply globally to all controllers and devices. It clears the devices container and resets the device list since
723
+ * global options don't have associated devices.
724
+ *
725
+ * @private
726
+ */
727
+ #showGlobalOptions() {
564
728
 
565
- if(currentDevice) {
566
729
 
567
- upstreamOption = true;
568
- }
730
+ // Clear the devices container since global options don't have associated devices, but only when we have controllers defined.
731
+ if(this.#getControllers) {
569
732
 
570
- break;
733
+ this.#devicesContainer.textContent = "";
734
+ }
571
735
 
572
- default:
736
+ // Highlight the global options entry
737
+ this.#highlightSelectedController(null);
573
738
 
574
- break;
575
- }
739
+ // Show global options
740
+ this.#showDeviceOptions("Global Options");
741
+ }
576
742
 
577
- // We're currently in an indetermindate state and transitioning to an unchecked state.
578
- if(checkbox.readOnly) {
743
+ /**
744
+ * Show controller options by loading its devices and displaying the controller's configuration.
745
+ *
746
+ * This displays the feature options for a specific controller. It finds the controller by its serial number and loads its associated devices.
747
+ *
748
+ * @param {string} controllerSerial - The serial number of the controller to show options for.
749
+ * @private
750
+ */
751
+ async #showControllerOptions(controllerSerial) {
579
752
 
580
- // 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
581
- // the readOnly property to let us know that we've just cycled from an indeterminate state.
582
- checkbox.checked = checkbox.readOnly = false;
753
+ const entry = (await this.#getControllers())?.find(c => c.serialNumber === controllerSerial);
583
754
 
584
- // If we have a value-centric feature option, we show the default value when we're in an indeterminate state.
585
- if(this.#featureOptions.isValue(featureOption)) {
755
+ if(!entry) {
586
756
 
587
- // If we're unchecked, clear out the value and make it read only. We show the system default for reference.
588
- inputValue.value = option.defaultValue;
589
- inputValue.readOnly = true;
590
- }
591
- } else if(!checkbox.checked) {
757
+ return;
758
+ }
592
759
 
593
- // We're currently in a checked state and transitioning to an unchecked or an indeterminate state.
760
+ await this.#showSidebar(entry);
761
+ }
594
762
 
595
- // 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.
596
- if(upstreamOption) {
763
+ /**
764
+ * Render the feature options webUI.
765
+ *
766
+ * 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
767
+ * is async because it needs to fetch configuration data from Homebridge and potentially connect to network controllers.
768
+ *
769
+ * @returns {Promise<void>}
770
+ * @public
771
+ */
772
+ async show() {
597
773
 
598
- // 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
599
- // indeterminate state. This allows us to effectively cycle between three states.
600
- checkbox.readOnly = checkbox.indeterminate = true;
601
- }
774
+ // Show the beachball while we setup. The user needs feedback that something is happening during the async operations.
775
+ homebridge.showSpinner();
776
+ homebridge.hideSchemaForm();
602
777
 
603
- // If we're in an indeterminate state, we need to traverse the tree to get the upstream value we're inheriting.
604
- if(this.#featureOptions.isValue(featureOption)) {
778
+ // Update our menu button states to show we're on the feature options page. This provides visual navigation feedback to the user.
779
+ this.#updateMenuState();
605
780
 
606
- let newInputValue;
781
+ // Show the feature options page and hide the support page. These are mutually exclusive views in the Homebridge UI.
782
+ document.getElementById("pageSupport").style.display = "none";
783
+ document.getElementById("pageFeatureOptions").style.display = "block";
607
784
 
608
- // If our scope is global, let's fallback on the default value.
609
- // eslint-disable-next-line eqeqeq
610
- if((currentDevice?.serialNumber == null) && (this.#controller == null)) {
785
+ // Hide the UI elements and cleanup any listeners until we're ready to show them. This prevents visual flickering as we build the interface.
786
+ this.hide();
611
787
 
612
- newInputValue = option.defaultValue;
613
- } else if(currentDevice?.serialNumber !== this.#controller) {
788
+ // Make sure we have the refreshed configuration. This ensures we're always working with the latest saved settings.
789
+ this.currentConfig = await homebridge.getPluginConfig();
614
790
 
615
- // We're at the device level - get the controller level value if it exists and fallback to the global value otherwise.
616
- newInputValue = this.#featureOptions.value(checkbox.id, this.#controller) ?? this.#featureOptions.value(checkbox.id);
617
- } else {
791
+ // Keep our revert snapshot aligned with whatever was *last saved* (not just first render).
792
+ // We compare to the current config and update the snapshot if it differs, so "Revert to Saved" reflects the latest saved state.
793
+ const loadedOptions = (this.currentConfig[0]?.options ?? []);
618
794
 
619
- // We're at the controller level - get the global value.
620
- newInputValue = this.#featureOptions.value(checkbox.id);
621
- }
795
+ if(!this.#initialFeatureOptions || !this.#sameStringArray(this.#initialFeatureOptions, loadedOptions)) {
622
796
 
623
- // Our fallback if there's no value defined within the scope hierarchy is the default value.
624
- inputValue.value = newInputValue ?? option.defaultValue;
625
- inputValue.readOnly = true;
626
- }
627
- } else if(checkbox.checked) {
797
+ this.#initialFeatureOptions = [...loadedOptions];
798
+ }
628
799
 
629
- // We're currently in an unchecked state and transitioning to a checked state.
630
- checkbox.readOnly = checkbox.indeterminate = false;
800
+ // Retrieve the set of feature options available to us. This comes from the plugin backend and defines what options can be configured.
801
+ const features = (await homebridge.request("/getOptions")) ?? [];
631
802
 
632
- if(this.#featureOptions.isValue(featureOption)) {
803
+ // Initialize our feature option configuration. This creates the data structure that manages option states and hierarchies.
804
+ this.#featureOptions = new FeatureOptions(features.categories, features.options, this.currentConfig[0].options ?? []);
633
805
 
634
- inputValue.readOnly = false;
635
- }
636
- }
806
+ // Clear all our containers to start fresh. This ensures no stale content remains from previous displays.
807
+ this.#clearContainers();
637
808
 
638
- // 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
639
- // provide a visual queue to the user, highlighting to indicate that a non-default option has been set.
640
- if(!checkbox.indeterminate && ((checkbox.checked !== option.default) ||
641
- (this.#featureOptions.isValue(featureOption) && (inputValue.value.toString() !== option.defaultValue.toString())) || upstreamOption)) {
809
+ // Ensure the DOM is ready before we render our UI. We wait for Bootstrap styles to be applied before proceeding.
810
+ await this.#waitForBootstrap();
642
811
 
643
- labelDescription.classList.add("text-info");
644
- newOptions.push((checkbox.checked ? "Enable." : "Disable.") + checkbox.value +
645
- (this.#featureOptions.isValue(featureOption) && checkbox.checked ? ("." + inputValue.value) : ""));
646
- } else {
812
+ // Add our custom styles for hover effects, dark mode support, and modern layouts. These enhance the visual experience and ensure consistency with the
813
+ // Homebridge UI theme.
814
+ this.#injectCustomStyles();
647
815
 
648
- // We've reset to the defaults, remove our highlighting.
649
- labelDescription.classList.remove("text-info");
650
- }
816
+ // Initialize event delegation for all UI interactions.
817
+ this.#initializeEventDelegation();
651
818
 
652
- // Update our configuration in Homebridge.
653
- this.currentConfig[0].options = newOptions;
654
- this.#featureOptions.configuredOptions = newOptions;
655
- await homebridge.updatePluginConfig(this.currentConfig);
819
+ // Hide the search panel initially until content is loaded.
820
+ if(this.#searchPanel) {
656
821
 
657
- // If we've reset to defaults, make sure our color coding for scope is reflected.
658
- if((checkbox.checked === option.default) || checkbox.indeterminate) {
822
+ this.#searchPanel.style.display = "none";
823
+ }
659
824
 
660
- const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, this.#controller);
825
+ // Check if we have controllers configured when they're required. We can't show device options without at least one controller in controller mode.
826
+ if(this.#getControllers && !(await this.#getControllers())?.length) {
661
827
 
662
- if(scopeColor) {
828
+ this.#showNoControllersMessage();
829
+ homebridge.hideSpinner();
663
830
 
664
- labelDescription.classList.add(scopeColor);
665
- }
666
- }
831
+ return;
832
+ }
667
833
 
668
- // Adjust visibility of other feature options that depend on us.
669
- if(this.#featureOptions.groups[checkbox.id]) {
834
+ // Initialize our informational header with feature option precedence information. This helps users understand the inheritance hierarchy.
835
+ this.#initializeHeader();
670
836
 
671
- const entryVisibility = this.#featureOptions.test(featureOption, currentDevice?.serialNumber) ? "" : "none";
837
+ // Build the sidebar with global options and controllers/devices. This creates the navigation structure for the UI.
838
+ await this.#buildSidebar();
672
839
 
673
- // Lookup each feature option setting and set the visibility accordingly.
674
- for(const entry of this.#featureOptions.groups[checkbox.id]) {
840
+ // All done. Let the user interact with us.
841
+ homebridge.hideSpinner();
675
842
 
676
- document.getElementById("row-" + entry).style.display = entryVisibility;
677
- }
678
- }
679
- });
843
+ // Default the user to the global settings if we have no controllers. Otherwise, show the first controller to give them a starting point.
844
+ await this.#showSidebar((await this.#getControllers?.())?.[0] ?? null);
845
+ }
680
846
 
681
- // Add the actual description for the option after the checkbox.
682
- labelDescription.appendChild(document.createTextNode(option.description));
847
+ /**
848
+ * Wait for Bootstrap to finish loading in the DOM so we can render our UI properly, or until the timeout expires.
849
+ *
850
+ * 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
851
+ * proceed anyway to avoid blocking the UI indefinitely.
852
+ *
853
+ * @param {number} [timeoutMs=2000] - Maximum time to wait for Bootstrap in milliseconds.
854
+ * @param {number} [intervalMs=20] - Interval between checks in milliseconds.
855
+ * @returns {Promise<boolean>} True if Bootstrap was detected, false if timeout was reached.
856
+ * @private
857
+ */
858
+ async #waitForBootstrap(timeoutMs = 2000, intervalMs = 20) {
683
859
 
684
- // Add the label to the table cell.
685
- tdLabel.appendChild(labelDescription);
860
+ // Record when we started so we know how long we have been waiting.
861
+ const startTime = Date.now();
686
862
 
687
- // Provide a cell-wide target to click on options.
688
- tdLabel.addEventListener("click", () => checkbox.click());
863
+ // This helper checks whether Bootstrap's styles are currently applied.
864
+ const isBootstrapApplied = () => {
689
865
 
690
- // Add the label table cell to the table row.
691
- trX.appendChild(tdLabel);
866
+ // We create a temporary test element and apply the "d-none" class.
867
+ const testElem = document.createElement("div");
692
868
 
693
- // Adjust the visibility of the feature option, if it's logically grouped.
694
- if((option.group !== undefined) && !this.#featureOptions.test(category.name + (option.group.length ? ("." + option.group) : ""), currentDevice?.serialNumber)) {
869
+ testElem.className = "d-none";
870
+ document.body.appendChild(testElem);
695
871
 
696
- trX.style.display = "none";
697
- } else {
872
+ // If Bootstrap is loaded, the computed display value should be "none".
873
+ const display = getComputedStyle(testElem).display;
698
874
 
699
- // Increment the visible option count.
700
- optionsVisibleCount++;
701
- }
875
+ // Remove our test element to avoid leaving behind clutter.
876
+ document.body.removeChild(testElem);
702
877
 
703
- // Add the table row to the table body.
704
- tbody.appendChild(trX);
705
- }
878
+ // Return true if the Bootstrap style is detected.
879
+ return display === "none";
880
+ };
706
881
 
707
- // Add the table body to the table.
708
- optionTable.appendChild(tbody);
882
+ // We loop until Bootstrap is detected or we reach our timeout.
883
+ while(Date.now() - startTime < timeoutMs) {
709
884
 
710
- // If we have no options visible in a given category, then hide the entire category.
711
- if(!optionsVisibleCount) {
885
+ // If Bootstrap is active, we can stop waiting.
886
+ if(isBootstrapApplied()) {
712
887
 
713
- optionTable.style.display = "none";
888
+ return true;
714
889
  }
715
890
 
716
- // Add the table to the page.
717
- this.#configTable.appendChild(optionTable);
891
+ // Otherwise, we pause for a short interval before checking again.
892
+ // eslint-disable-next-line no-await-in-loop
893
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
718
894
  }
719
895
 
720
- homebridge.hideSpinner();
721
- }
722
-
723
- // Our default device information panel handler.
724
- #showDeviceInfoPanel(device) {
896
+ return false;
897
+ };
725
898
 
726
- const deviceFirmware = document.getElementById("device_firmware") ?? {};
727
- const deviceSerial = document.getElementById("device_serial") ?? {};
899
+ /**
900
+ * Update the menu button states to reflect the current page.
901
+ *
902
+ * This provides visual feedback about which section of the plugin config the user is currently viewing. We swap between the elegant and primary button
903
+ * styles to show active/inactive states.
904
+ *
905
+ * @private
906
+ */
907
+ #updateMenuState() {
728
908
 
729
- // No device specified, we must be in a global context.
730
- if(!device) {
909
+ const menuStates = [
910
+ { id: "menuHome", primary: true },
911
+ { id: "menuFeatureOptions", primary: false },
912
+ { id: "menuSettings", primary: true }
913
+ ];
731
914
 
732
- deviceFirmware.innerHTML = "N/A";
733
- deviceSerial.innerHTML = "N/A";
915
+ for(const { id, primary } of menuStates) {
734
916
 
735
- return;
917
+ this.#toggleClasses(document.getElementById(id),
918
+ primary ? ["btn-primary"] : ["btn-elegant"],
919
+ primary ? ["btn-elegant"] : ["btn-primary"]);
736
920
  }
737
-
738
- // Display our device details.
739
- deviceFirmware.innerHTML = device.firmwareVersion;
740
- deviceSerial.innerHTML = device.serialNumber;
741
921
  }
742
922
 
743
- // Default method for enumerating the device list in the sidebar.
744
- #showSidebarDevices() {
923
+ /**
924
+ * Clear all containers to prepare for fresh content.
925
+ *
926
+ * 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
927
+ * maintain consistency between the UI state and the displayed content.
928
+ *
929
+ * @private
930
+ */
931
+ #clearContainers() {
745
932
 
746
- // Show the devices list only if we have actual devices to show.
747
- if(!this.#devices?.length) {
933
+ for(const id of [ "controllersContainer", "devicesContainer", "configTable" ]) {
748
934
 
749
- return;
935
+ const container = document.getElementById(id);
936
+
937
+ if(container) {
938
+
939
+ container.textContent = "";
940
+ }
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Show a message when no controllers are configured.
946
+ *
947
+ * 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
948
+ * configure in controller mode.
949
+ *
950
+ * @private
951
+ */
952
+ #showNoControllersMessage() {
953
+
954
+ const headerInfo = document.getElementById("headerInfo");
955
+
956
+ headerInfo.textContent = "Please configure a controller to access in the main settings tab before configuring feature options.";
957
+ headerInfo.style.display = "";
958
+ }
959
+
960
+ /**
961
+ * Initialize the informational header showing feature option precedence.
962
+ *
963
+ * This header educates users about how options inherit through the hierarchy. Understanding this inheritance model is crucial for effective configuration,
964
+ * so we make it prominent at the top of the interface. The header adapts based on whether controllers are being used.
965
+ *
966
+ * @private
967
+ */
968
+ #initializeHeader() {
969
+
970
+ const headerInfo = document.getElementById("headerInfo");
971
+
972
+ headerInfo.style.fontWeight = "bold";
973
+ headerInfo.innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:" +
974
+ "<br><i class=\"text-warning\">Global options</i> (lowest priority) &rarr; " +
975
+ (this.#getControllers ? "<i class=\"text-success\">Controller options</i> &rarr; " : "") +
976
+ "<i class=\"text-info\">Device options</i> (highest priority)";
977
+ }
978
+
979
+ /**
980
+ * Build the sidebar with global options and controllers.
981
+ *
982
+ * The sidebar provides the primary navigation for the feature options UI. It always includes a global options entry and optionally includes controllers if
983
+ * the plugin is configured to use them. The sidebar structure determines how users navigate between different configuration scopes.
984
+ *
985
+ * @private
986
+ */
987
+ async #buildSidebar() {
988
+
989
+ // Create the global options entry - this is always present. Global options apply to all devices and provide baseline configuration.
990
+ this.#createGlobalOptionsEntry(this.#controllersContainer);
991
+
992
+ // Create controller entries if we're using controllers. Controllers provide an intermediate level of configuration between global and device-specific.
993
+ if(this.#getControllers) {
994
+
995
+ await this.#createControllerEntries(this.#controllersContainer);
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Create the global options entry in the sidebar.
1001
+ *
1002
+ * Global options are always available and provide the baseline configuration that all controllers and devices inherit from. This entry is styled differently
1003
+ * to indicate its special status and is added to the appropriate tracking list based on whether controllers are present.
1004
+ *
1005
+ * @param {HTMLElement} controllersContainer - The container to add the entry to.
1006
+ * @private
1007
+ */
1008
+ #createGlobalOptionsEntry(controllersContainer) {
1009
+
1010
+ const globalLink = this.#createElement("a", {
1011
+
1012
+ classList: [ "nav-link", "nav-header", "text-decoration-none", "text-uppercase", "fw-bold" ],
1013
+ "data-navigation": "global",
1014
+ href: "#",
1015
+ name: "Global Options",
1016
+ role: "button"
1017
+ }, ["Global Options"]);
1018
+
1019
+ controllersContainer.appendChild(globalLink);
1020
+ }
1021
+
1022
+ /**
1023
+ * Create controller entries in the sidebar.
1024
+ *
1025
+ * Controllers represent network devices that manage multiple accessories. Each controller gets its own entry in the sidebar, allowing users to configure
1026
+ * options at the controller level that apply to all its devices. Controllers are displayed with their configured names for easy identification.
1027
+ *
1028
+ * @param {HTMLElement} controllersContainer - The container to add entries to.
1029
+ * @private
1030
+ */
1031
+ async #createControllerEntries(controllersContainer) {
1032
+
1033
+ // If we don't have controllers defined, we're done.
1034
+ if(!this.#getControllers) {
1035
+
1036
+ return;
1037
+ }
1038
+
1039
+ // Create the controller category header. This visually groups all controllers together and uses the configured label.
1040
+ const categoryHeader = this.#createElement("h6", {
1041
+
1042
+ classList: [ "nav-header", "text-muted", "text-uppercase", "small", "mb-1" ]
1043
+ }, [this.#sidebar.controllerLabel]);
1044
+
1045
+ controllersContainer.appendChild(categoryHeader);
1046
+
1047
+ // Create an entry for each controller. Controllers are identified by their serial number and displayed with their friendly name.
1048
+ for(const controller of (await this.#getControllers())) {
1049
+
1050
+ const link = this.#createElement("a", {
1051
+
1052
+ classList: [ "nav-link", "text-decoration-none" ],
1053
+ "data-device-serial": controller.serialNumber,
1054
+ "data-navigation": "controller",
1055
+ href: "#",
1056
+ name: controller.serialNumber,
1057
+ role: "button"
1058
+ }, [controller.name]);
1059
+
1060
+ controllersContainer.appendChild(link);
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Inject custom styles for hover effects, dark mode support, and modern layouts.
1066
+ *
1067
+ * These styles enhance the visual experience and ensure our UI integrates well with both light and dark modes. We use media queries to automatically adapt
1068
+ * to the user's system preferences. The styles include support for flexbox layouts, responsive design, and theme-aware coloring.
1069
+ *
1070
+ * @private
1071
+ */
1072
+ #injectCustomStyles() {
1073
+
1074
+ // Ensure we do not inject duplicate styles when re-entering this view. We make this idempotent for stability across navigations.
1075
+ if(document.getElementById("feature-options-styles")) {
1076
+
1077
+ return;
1078
+ }
1079
+
1080
+ // Extract our theme color from .btn-primary to ensure consistency with the Homebridge theme.
1081
+ const probeBtn = document.createElement("button");
1082
+
1083
+ probeBtn.className = "btn btn-primary";
1084
+ probeBtn.style.display = "none";
1085
+ document.body.appendChild(probeBtn);
1086
+
1087
+ this.#themeColor.background = getComputedStyle(probeBtn).backgroundColor;
1088
+ this.#themeColor.text = getComputedStyle(probeBtn).color;
1089
+
1090
+ document.body.removeChild(probeBtn);
1091
+
1092
+ // Quick utility to help us convert RGB values to RGBA for use in CSS.
1093
+ const rgba = (rgb, alpha) => {
1094
+
1095
+ const match = rgb.match(/\d+/g);
1096
+
1097
+ if(!match || (match.length < 3)) {
1098
+
1099
+ return rgb;
1100
+ }
1101
+
1102
+ return "rgba(" + match[0] + ", " + match[1] + ", " + match[2] + ", " + alpha + ")";
1103
+ };
1104
+
1105
+ const styles = [
1106
+
1107
+ /* eslint-disable @stylistic/max-len */
1108
+ // Remove margin collapse and enable clean layout flow.
1109
+ "html, body { margin: 0; padding: 0; }",
1110
+
1111
+ // Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
1112
+ "body { background-color: #fff !important; }",
1113
+
1114
+ // Page root uses a column layout with full width.
1115
+ "#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
1116
+
1117
+ // Sidebar + content layout is horizontal (row).
1118
+ ".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
1119
+
1120
+ // Sidebar layout and appearance.
1121
+ "#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; background-color: var(--bs-gray-100); position: relative; }",
1122
+
1123
+ // Remove internal scrolling from sidebar content.
1124
+ "#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
1125
+
1126
+ // Sidebar containers.
1127
+ "#controllersContainer { padding: 0; margin-bottom: 0; }",
1128
+ "#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
1129
+
1130
+ // Feature content (right-hand pane).
1131
+ ".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
1132
+ ".category-border { border: 1px solid " + this.#themeColor.background + " !important; box-shadow: 0 0 0 1px " + rgba(this.#themeColor.background, 0.1) + "; }",
1133
+
1134
+ // Ensure the table itself uses separate borders when we have rounded tbody elements. This is necessary for border-radius to work properly.
1135
+ "table[data-category] { border-collapse: separate !important; border-spacing: 0; }",
1136
+
1137
+ // How we define row visibility for feature options. We need this complexity because we hide or make visible rows depending on what the user has chosen to expose.
1138
+ //
1139
+ // "table[data-category] tbody tr.fo-visible,"
1140
+ // "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']){}",
1141
+
1142
+ // Create the outer border of the table on the left and right sides.
1143
+ "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:first-child{",
1144
+ " border-left:1px solid " + this.#themeColor.background + ";",
1145
+ "}",
1146
+ "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:last-child{",
1147
+ " border-right:1px solid " + this.#themeColor.background + ";",
1148
+ "}",
1149
+
1150
+ // Provide the top border on the first visible row.
1151
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
1152
+ " border-top:1px solid " + this.#themeColor.background + ";",
1153
+ "}",
1154
+
1155
+ // Provide the bottom border on the last visible row.
1156
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
1157
+ " border-bottom:1px solid " + this.#themeColor.background + ";",
1158
+ "}",
1159
+
1160
+ // Create rounded corners at the top and bottom rows.
1161
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
1162
+ " border-top-left-radius:.5rem;",
1163
+ "}",
1164
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
1165
+ " border-top-right-radius:.5rem;",
1166
+ "}",
1167
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
1168
+ " border-bottom-left-radius:.5rem;",
1169
+ "}",
1170
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
1171
+ " border-bottom-right-radius:.5rem;",
1172
+ "}",
1173
+
1174
+ // Main options area - remove scroll behavior, just layout styling.
1175
+ ".options-content { padding: 1rem; margin: 0; }",
1176
+
1177
+ // Info header styling.
1178
+ "#headerInfo { flex-shrink: 0; padding: 0.5rem !important; margin-bottom: 0.5rem !important; }",
1179
+
1180
+ // Device stats grid layout.
1181
+ ".device-stats-grid { display: flex; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem; padding: 0 0.75rem; flex-wrap: nowrap; overflow: hidden; }",
1182
+ ".device-stats-grid .stat-item:first-child { flex: 0 0 25% }",
1183
+ ".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
1184
+
1185
+ ".stat-item { display: flex; flex-direction: column; gap: 0.125rem; }",
1186
+ ".stat-label { font-weight: 600; color: var(--bs-gray-600); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
1187
+ ".stat-value { font-size: 0.875rem; color: var(--bs-body-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
1188
+
1189
+ // Responsive hiding for our device stats grid.
1190
+ "@media (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
1191
+ "@media (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
1192
+ "@media (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
1193
+
1194
+ // Responsive hiding for feature option status information.
1195
+ "@media (max-width: 400px) { #statusInfo { display: none !important; } }",
1196
+
1197
+ // Navigation styles.
1198
+ ".nav-link { border-radius: 0.375rem; transition: all 0.2s; position: relative; padding: 0.25rem 0.75rem !important; line-height: 1.2; font-size: 0.8125rem; }",
1199
+ ".nav-link:hover { background-color: " + rgba(this.#themeColor.background, 0.1) + "; color: " + this.#themeColor.background + " !important; }",
1200
+ ".nav-link.active { background-color: " + this.#themeColor.background + "; color: " + this.#themeColor.text + " !important; }",
1201
+ ".nav-header { border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin-bottom: 0.125rem; padding: 0.25rem 0.75rem !important; font-size: 0.75rem !important; line-height: 1.2; }",
1202
+ "#devicesContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
1203
+ "#controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
1204
+
1205
+ // Search bar.
1206
+ ".search-toolbar { border-radius: 0.5rem; padding: 0 0 0.5rem 0; }",
1207
+ ".search-input-wrapper { min-width: 0; }",
1208
+ ".filter-pills { display: flex; gap: 0.5rem; flex-wrap: wrap; }",
1209
+
1210
+ // Grouped option visual indicator.
1211
+ ".grouped-option { background-color: " + rgba(this.#themeColor.background, 0.08) + "; }",
1212
+ ".grouped-option td:nth-child(2) label { padding-left: 20px; position: relative; }",
1213
+ ".grouped-option td:nth-child(2) label::before { content: \"\\21B3\"; position: absolute; left: 4px; color: #666; }",
1214
+
1215
+ // Dark mode refinements.
1216
+ "@media (prefers-color-scheme: dark) {",
1217
+
1218
+ // Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
1219
+ " body { background-color: #242424 !important; }",
1220
+ " #sidebar { background-color: #1A1A1A !important; }",
1221
+ " .nav-header { border-bottom-color: rgba(255, 255, 255, 0.1); }",
1222
+ " .text-body { color: #999 !important; }",
1223
+ " .text-muted { color: #999 !important; }",
1224
+ " .device-stats-grid { background-color: #1A1A1A; border-color: #444; }",
1225
+ " .stat-label { color: #999; }",
1226
+ " .stat-value { color: #999; }",
1227
+ " #search .form-control { background-color: #1A1A1A; border-color: #444; color: #F8F9FA; }",
1228
+ " #search .form-control:focus { background-color: #1A1A1A; border-color: #666; color: #F8F9FA; box-shadow: 0 0 0 0.2rem rgba(255, 160, 0, 0.25); }",
1229
+ " #search .form-control::placeholder { color: #999; }",
1230
+ " #statusInfo .text-muted { color: #B8B8B8 !important; }",
1231
+ "}",
1232
+
1233
+ // Table hover styling.
1234
+ ".table-hover tbody tr { transition: background-color 0.15s; }",
1235
+ ".table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); }",
1236
+ "@media (prefers-color-scheme: dark) { .table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.20); } }",
1237
+
1238
+ // Utility styles.
1239
+ ".btn-xs { font-size: 0.75rem !important; padding: 0.125rem 0.5rem !important; line-height: 1.5; touch-action: manipulation; }",
1240
+ ".cursor-pointer { cursor: pointer; }",
1241
+ ".user-select-none { user-select: none; -webkit-user-select: none; }",
1242
+
1243
+ // Use CSS for the category header hover emphasis to avoid JS event handlers for simple hover effects.
1244
+ "table[data-category] thead th[role='button']:hover { color: " + this.#themeColor.background + " !important; }",
1245
+
1246
+ // Respect reduced motion settings for accessibility.
1247
+ "@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
1248
+ /* eslint-enable @stylistic/max-len */
1249
+ ];
1250
+
1251
+ const styleElement = this.#createElement("style", { id: "feature-options-styles" }, [styles.join("\n")]);
1252
+
1253
+ document.head.appendChild(styleElement);
1254
+ }
1255
+
1256
+ /**
1257
+ * Show the device list taking the controller context into account.
1258
+ *
1259
+ * 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
1260
+ * feature options for the selected context. For controllers, it loads devices from the network. For global options, it shows global configuration.
1261
+ *
1262
+ * @param {Controller|null} controller - The controller to show devices for, or null for global options.
1263
+ * @returns {Promise<void>}
1264
+ * @private
1265
+ */
1266
+ async #showSidebar(controller) {
1267
+
1268
+ // Show the beachball while we setup. Loading devices from a controller can take time, especially over the network.
1269
+ homebridge.showSpinner();
1270
+
1271
+ // Grab the list of devices we're displaying. This might involve a network request to the controller or reading from the Homebridge cache.
1272
+ this.#devices = await this.#getDevices(controller);
1273
+
1274
+ if(this.#getControllers) {
1275
+
1276
+ // Highlight the selected controller. This provides visual feedback about which controller's devices we're currently viewing.
1277
+ this.#highlightSelectedController(controller);
1278
+
1279
+ // Handle connection errors. If we can't connect to a controller, we need to inform the user rather than showing an empty list.
1280
+ if(controller && !this.#devices?.length) {
1281
+
1282
+ await this.#showConnectionError();
1283
+
1284
+ return;
1285
+ }
1286
+
1287
+ // The first entry returned by getDevices() must always be the controller. This convention allows us to show controller-specific options.
1288
+ this.#controller = this.#devices[0]?.serialNumber ?? null;
1289
+ }
1290
+
1291
+ // Make the UI visible. Now that we have our data, we can show the interface elements to the user.
1292
+ for(const id of ["headerInfo"]) {
1293
+
1294
+ const element = document.getElementById(id);
1295
+
1296
+ if(element) {
1297
+
1298
+ element.style.display = "";
1299
+ }
1300
+ }
1301
+
1302
+ // The sidebar should always be visible unless there's an error.
1303
+ const sidebar = document.getElementById("sidebar");
1304
+
1305
+ if(sidebar) {
1306
+
1307
+ sidebar.style.display = "";
1308
+ }
1309
+
1310
+ // Clear and populate the devices container. #showSidebarDevices is responsible for the actual display logic.
1311
+ this.#devicesContainer.textContent = "";
1312
+ this.#showSidebarDevices(controller, this.#devices);
1313
+
1314
+ // Display the feature options to the user. For controllers, we show the controller's options. For global context, we show global options.
1315
+ this.#showDeviceOptions(controller ? this.#devices[0].serialNumber : "Global Options");
1316
+
1317
+ // All done. Let the user interact with us.
1318
+ homebridge.hideSpinner();
1319
+ }
1320
+
1321
+ /**
1322
+ * Highlight the selected controller in the sidebar.
1323
+ *
1324
+ * This provides visual feedback about which controller is currently selected. We use the active class to indicate selection, which works well in both
1325
+ * light and dark modes thanks to our custom styles. The highlighting helps users maintain context as they navigate.
1326
+ *
1327
+ * @param {Controller|null} controller - The selected controller, or null for global options.
1328
+ * @private
1329
+ */
1330
+ #highlightSelectedController(controller) {
1331
+
1332
+ const selectedName = controller?.serialNumber ?? "Global Options";
1333
+
1334
+ for(const entry of document.querySelectorAll("#sidebar .nav-link[data-navigation]")) {
1335
+
1336
+ this.#toggleClasses(entry, (entry.name === selectedName) ? ["active"] : [], (entry.name === selectedName) ? [] : ["active"]);
1337
+ }
1338
+ }
1339
+
1340
+ /**
1341
+ * Show a connection error message with retry capability.
1342
+ *
1343
+ * 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
1344
+ * error message includes details from the backend and offers a retry button after a short delay.
1345
+ *
1346
+ * @returns {Promise<void>}
1347
+ * @private
1348
+ */
1349
+ async #showConnectionError() {
1350
+
1351
+ // Hide the sidebar and other UI elements that don't make sense without a connection.
1352
+ const sidebar = document.getElementById("sidebar");
1353
+
1354
+ if(sidebar) {
1355
+
1356
+ sidebar.style.display = "none";
1357
+ }
1358
+
1359
+ if(this.#deviceStatsContainer) {
1360
+
1361
+ this.#deviceStatsContainer.style.display = "none";
1362
+ }
1363
+
1364
+ if(this.#searchPanel) {
1365
+
1366
+ this.#searchPanel.style.display = "none";
1367
+ }
1368
+
1369
+ // Clear all containers to remove any stale content.
1370
+ this.#clearContainers();
1371
+
1372
+ const headerInfo = document.getElementById("headerInfo");
1373
+
1374
+ const errorMessage = [
1375
+
1376
+ "Unable to connect to the controller.",
1377
+ "Check the Settings tab to verify the controller details are correct.",
1378
+ "<code class=\"text-danger\">" + (await homebridge.request("/getErrorMessage")) + "</code>"
1379
+ ].join("<br>") + "<br>";
1380
+
1381
+ // Create a container div for the error message and future retry button. This allows us to add the button without replacing the entire content.
1382
+ const errorContainer = this.#createElement("div", {}, []);
1383
+
1384
+ errorContainer.innerHTML = errorMessage;
1385
+ headerInfo.textContent = "";
1386
+ headerInfo.appendChild(errorContainer);
1387
+ headerInfo.style.display = "";
1388
+
1389
+ // Wrapper that shrink-wraps its children
1390
+ const retryWrap = this.#createElement("div", { classList: "d-inline-block w-auto" });
1391
+
1392
+ // Create the retry button with consistent styling. We use the warning style to indicate this is a recovery action.
1393
+ const retryButton = this.#createElement("button", {
1394
+
1395
+ classList: "btn btn-warning btn-sm mt-3",
1396
+ textContent: "\u21BB Retry",
1397
+ type: "button"
1398
+ });
1399
+
1400
+ retryButton.disabled = true;
1401
+
1402
+ // Add the button to the error container. It appears below the error message with appropriate spacing.
1403
+ retryWrap.appendChild(retryButton);
1404
+
1405
+ // Add a slim progress bar that fills for 5s.
1406
+ const barWrap = this.#createElement("div", {
1407
+
1408
+ classList: "progress mt-1 w-100",
1409
+ style: { height: "4px" }
1410
+ }, [
1411
+
1412
+ this.#createElement("div", {
1413
+
1414
+ classList: "progress-bar",
1415
+ role: "progressbar",
1416
+ style: { width: "0%" }
1417
+ })
1418
+ ]);
1419
+
1420
+ retryWrap.appendChild(barWrap);
1421
+ errorContainer.appendChild(retryWrap);
1422
+
1423
+ // Kick off the fill animation on the next frame
1424
+ const bar = barWrap.querySelector(".progress-bar");
1425
+
1426
+ bar.style.setProperty("--bs-progress-bar-bg", this.#themeColor.background);
1427
+
1428
+ window.requestAnimationFrame(() => {
1429
+
1430
+ bar.style.transition = "width " + this.#ui.controllerRetryEnableDelayMs + "ms linear";
1431
+ bar.style.width = "100%";
1432
+ });
1433
+
1434
+ // 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
1435
+ // error message before seeing the action they can take.
1436
+ setTimeout(() => {
1437
+
1438
+ retryButton.disabled = false;
1439
+ barWrap.remove();
1440
+
1441
+ // Set up the retry handler. When clicked, we'll refresh the entire UI which will retry all connections and rebuild the interface.
1442
+ this.#addEventListener(retryButton, "click", async () => {
1443
+
1444
+ // Provide immediate feedback that we're retrying. The button becomes disabled to prevent multiple simultaneous retry attempts.
1445
+ retryButton.disabled = true;
1446
+ retryButton.textContent = "Retrying...";
1447
+
1448
+ // Refresh our UI which will force a reconnection.
1449
+ this.cleanup();
1450
+ await this.show();
1451
+ });
1452
+ }, this.#ui.controllerRetryEnableDelayMs);
1453
+
1454
+ homebridge.hideSpinner();
1455
+ }
1456
+
1457
+ /**
1458
+ * Show feature option information for a specific device, controller, or globally.
1459
+ *
1460
+ * This is the main method for displaying feature options. It handles all three contexts (global, controller, device) and builds the appropriate UI elements
1461
+ * including search, filters, and the option tables themselves. The display adapts based on the current scope and available options.
1462
+ *
1463
+ * @param {string} deviceId - The device serial number, or "Global Options" for global context.
1464
+ * @public
1465
+ */
1466
+ #showDeviceOptions(deviceId) {
1467
+
1468
+ homebridge.showSpinner();
1469
+
1470
+ // Clean up event listeners from previous option displays. This ensures we don't accumulate listeners as users navigate between devices.
1471
+ this.#cleanupOptionEventListeners();
1472
+
1473
+ // Update the selected device highlighting. This provides visual feedback in the sidebar about which device's options are being displayed.
1474
+ this.#highlightSelectedDevice(deviceId);
1475
+
1476
+ // Find the current device and update the info panel. The info panel shows device-specific information like firmware version and serial number.
1477
+ const currentDevice = this.#devices.find(device => device.serialNumber === deviceId);
1478
+
1479
+ this.#updateDeviceInfoPanel(currentDevice);
1480
+
1481
+ // Clear the configuration table for fresh content. We rebuild the entire option display for each device to ensure accuracy.
1482
+ this.#configTable.textContent = "";
1483
+
1484
+ // Initialize the search UI if it exists. The search UI includes the search box, filters, and status information.
1485
+ this.#initializeSearchUI();
1486
+
1487
+ // Create option tables for each category. Categories group related options together for better organization.
1488
+ this.#createOptionTables(currentDevice);
1489
+
1490
+ // Set up search functionality if available. This includes debounced search and keyboard shortcuts.
1491
+ this.#setupSearchFunctionality();
1492
+
1493
+ // Display the table.
1494
+ document.getElementById("optionsContainer").style.display = "";
1495
+
1496
+ homebridge.hideSpinner();
1497
+ }
1498
+
1499
+ /**
1500
+ * Clean up event listeners specific to option displays.
1501
+ *
1502
+ * When switching between devices, we need to clean up listeners attached to option elements. We identify these by checking if they're within the config
1503
+ * table, preserving sidebar navigation listeners. This prevents memory leaks from accumulating listeners.
1504
+ *
1505
+ * @private
1506
+ */
1507
+ #cleanupOptionEventListeners() {
1508
+
1509
+ // Remove listeners that are specific to options (not sidebar navigation). We build a list first to avoid modifying the map while iterating.
1510
+ const keysToRemove = [];
1511
+
1512
+ for(const [ key, listener ] of this.#eventListeners.entries()) {
1513
+
1514
+ // Identify option-specific listeners by checking if they're on config table elements. The closest method will find the config table if the element is
1515
+ // within it.
1516
+ if(listener.element.closest && listener.element.closest("#configTable")) {
1517
+
1518
+ keysToRemove.push(key);
1519
+ }
1520
+ }
1521
+
1522
+ // Remove the identified listeners. We do this as a separate step to avoid iterator invalidation issues.
1523
+ for(const key of keysToRemove) {
1524
+
1525
+ this.#removeEventListener(key);
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * Highlight the selected device in the sidebar.
1531
+ *
1532
+ * Similar to controller highlighting, this provides visual feedback about which device's options are currently being displayed. The highlighting helps users
1533
+ * maintain context as they navigate through multiple devices.
1534
+ *
1535
+ * @param {string} deviceId - The serial number of the selected device.
1536
+ * @private
1537
+ */
1538
+ #highlightSelectedDevice(deviceId) {
1539
+
1540
+ // cover both device links and the single "Global Options" link
1541
+ const links = document.querySelectorAll("#sidebar .nav-link[data-navigation='device'], " + "#sidebar .nav-link[data-navigation='global']");
1542
+
1543
+ for(const entry of links) {
1544
+
1545
+ const shouldBeActive = entry.name === deviceId;
1546
+
1547
+ this.#toggleClasses(entry, shouldBeActive ? ["active"] : [], shouldBeActive ? [] : ["active"]);
1548
+ }
1549
+ }
1550
+
1551
+ /**
1552
+ * Update the device information panel with device-specific details.
1553
+ *
1554
+ * The info panel shows device-specific details using a responsive grid layout. We hide it for global context since there's no specific device to show
1555
+ * information about. The panel content is customizable through the infoPanel configuration option.
1556
+ *
1557
+ * @param {Device|undefined} device - The device to show information for.
1558
+ * @private
1559
+ */
1560
+ #updateDeviceInfoPanel(device) {
1561
+
1562
+ // Ensure we've got the device statistics container available.
1563
+ if(!this.#deviceStatsContainer) {
1564
+
1565
+ return;
1566
+ }
1567
+
1568
+ this.#deviceStatsContainer.style.display = device ? "" : "none";
1569
+ this.#infoPanel(device);
1570
+ }
1571
+
1572
+ /**
1573
+ * Initialize the search UI components including search bar, filters, and status display.
1574
+ *
1575
+ * The search UI provides powerful filtering capabilities for finding specific options. It includes a search box, filter buttons, and status information about
1576
+ * the current view. The UI is rebuilt fresh each time to ensure it reflects the current option set.
1577
+ *
1578
+ * @private
1579
+ */
1580
+ #initializeSearchUI() {
1581
+
1582
+ if(!this.#searchPanel) {
1583
+
1584
+ return;
1585
+ }
1586
+
1587
+ // Clear existing content. We rebuild the search UI fresh each time to ensure it reflects the current option set.
1588
+ this.#searchPanel.textContent = "";
1589
+ this.#searchPanel.className = "";
1590
+
1591
+ // Create the status bar. This shows counts and provides a reset button.
1592
+ const statusBar = this.#createStatusBar();
1593
+
1594
+ this.#searchPanel.appendChild(statusBar);
1595
+
1596
+ // Create the main control bar. This contains the search input and filters.
1597
+ const controlBar = this.#createControlBar();
1598
+
1599
+ this.#searchPanel.appendChild(controlBar);
1600
+ this.#searchPanel.style.display = "";
1601
+ }
1602
+
1603
+ /**
1604
+ * Create the status bar showing option counts and reset button.
1605
+ *
1606
+ * The status bar provides at-a-glance information about how many options are available, how many have been modified, and how many match current filters. It
1607
+ * also provides a convenient compound reset button that reveals different reset options when clicked.
1608
+ *
1609
+ * @returns {HTMLElement} The status bar element.
1610
+ * @private
1611
+ */
1612
+ #createStatusBar() {
1613
+
1614
+ const statusInfo = this.#createElement("div", {
1615
+
1616
+ id: "statusInfo",
1617
+ style: { flex: "1 1 auto", minWidth: "0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }
1618
+ }, [
1619
+ this.#createElement("span", { classList: "text-muted" }, [
1620
+ this.#createElement("strong", {}, ["0"]),
1621
+ " total options \u00B7 ",
1622
+ this.#createElement("strong", { classList: "text-warning" }, ["0"]),
1623
+ " modified \u00B7 ",
1624
+ this.#createElement("strong", { classList: "text-info" }, ["0"]),
1625
+ " grouped \u00B7 ",
1626
+ this.#createElement("strong", { classList: "text-success" }, ["0"]),
1627
+ " visible"
1628
+ ])
1629
+ ]);
1630
+
1631
+ const resetBtn = this.#createResetButton();
1632
+
1633
+ return this.#createElement("div", {
1634
+
1635
+ classList: "d-flex justify-content-between align-items-center px-2 py-1 mb-1 alert-info rounded",
1636
+ id: "featureStatusBar",
1637
+ style: { alignItems: "center", display: "flex", fontSize: "0.8125rem", gap: "0.5rem" }
1638
+ }, [ statusInfo, resetBtn ]);
1639
+ }
1640
+
1641
+ /**
1642
+ * Create a compound reset button group with multiple reset options.
1643
+ *
1644
+ * The reset button initially displays as "Reset...". When clicked, it reveals two action buttons: "Reset to Defaults" and "Revert to Saved". This gives
1645
+ * users the ability to choose between clearing all options or reverting to the last saved state.
1646
+ *
1647
+ * @returns {HTMLElement} The reset button container.
1648
+ * @private
1649
+ */
1650
+ #createResetButton() {
1651
+
1652
+ const resetContainer = this.#createElement("div", {
1653
+
1654
+ classList: [ "d-flex", "align-items-center", "gap-1" ],
1655
+ role: "group"
1656
+ });
1657
+
1658
+ // Primary reset button that toggles the action buttons.
1659
+ const toggleBtn = this.#createElement("button", {
1660
+
1661
+ classList: "btn btn-xs btn-outline-danger cursor-pointer text-truncate user-select-none",
1662
+ "data-action": "reset-toggle",
1663
+ style: { fontSize: "0.75rem", marginLeft: "auto", padding: "0.25rem 0.5rem" },
1664
+ textContent: "Reset...",
1665
+ title: "Configuration reset options.",
1666
+ type: "button"
1667
+ });
1668
+
1669
+ // Reset to defaults button.
1670
+ const resetDefaultsBtn = this.#createElement("button", {
1671
+
1672
+ classList: "btn btn-xs btn-outline-danger cursor-pointer d-none text-truncate user-select-none",
1673
+ "data-action": "reset-defaults",
1674
+ style: { fontSize: "0.75rem", marginLeft: "auto", padding: "0.25rem 0.5rem" },
1675
+ textContent: "Reset to Defaults",
1676
+ title: "Reset all options to default values.",
1677
+ type: "button"
1678
+ });
1679
+
1680
+ // Revert to saved button.
1681
+ const revertBtn = this.#createElement("button", {
1682
+
1683
+ classList: "btn btn-xs btn-outline-danger cursor-pointer d-none text-truncate user-select-none",
1684
+ "data-action": "reset-revert",
1685
+ style: { fontSize: "0.75rem", marginLeft: "auto", padding: "0.25rem 0.5rem" },
1686
+ textContent: "Revert to Saved",
1687
+ title: "Revert options to the last saved configuration.",
1688
+ type: "button"
1689
+ });
1690
+
1691
+ resetContainer.appendChild(toggleBtn);
1692
+ resetContainer.appendChild(resetDefaultsBtn);
1693
+ resetContainer.appendChild(revertBtn);
1694
+
1695
+ return resetContainer;
1696
+ }
1697
+
1698
+ /**
1699
+ * Revert all options to the originally saved feature options.
1700
+ *
1701
+ * This restores the configuration to the state it was in when the UI was first shown. This is useful for undoing changes without losing the previously saved
1702
+ * configuration. The UI is refreshed to reflect the reverted state.
1703
+ *
1704
+ * @returns {Promise<void>}
1705
+ * @private
1706
+ */
1707
+ async #revertToInitialOptions() {
1708
+
1709
+ homebridge.showSpinner();
1710
+
1711
+ // Restore the initial options we saved during the first render or after the last detected save.
1712
+ this.currentConfig[0].options = [...this.#initialFeatureOptions];
1713
+ this.#featureOptions.configuredOptions = [...this.#initialFeatureOptions];
1714
+
1715
+ await homebridge.updatePluginConfig(this.currentConfig);
1716
+
1717
+ const selectedDevice = this.#devicesContainer.querySelector("a[data-navigation='device'].active");
1718
+
1719
+ this.#showDeviceOptions(selectedDevice?.name ?? "Global Options");
1720
+
1721
+ homebridge.hideSpinner();
1722
+
1723
+ this.#showRevertSuccessMessage();
1724
+ }
1725
+
1726
+ /**
1727
+ * Show a success message after reverting to saved configuration.
1728
+ *
1729
+ * This provides feedback that the revert action completed successfully. The message auto-dismisses to avoid cluttering the UI.
1730
+ *
1731
+ * @private
1732
+ */
1733
+ #showRevertSuccessMessage() {
1734
+
1735
+ const statusBar = document.getElementById("featureStatusBar");
1736
+
1737
+ if(!statusBar) {
1738
+
1739
+ return;
1740
+ }
1741
+
1742
+ const successMsg = this.#createElement("div", {
1743
+
1744
+ classList: "alert alert-info alert-dismissible fade show mt-2",
1745
+ innerHTML: "<strong>Options have been reverted to the last saved configuration.</strong>" +
1746
+ "<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\" aria-label=\"Close\"></button>",
1747
+ role: "alert"
1748
+ });
1749
+
1750
+ statusBar.insertAdjacentElement("afterend", successMsg);
1751
+
1752
+ setTimeout(() => {
1753
+
1754
+ successMsg.classList.remove("show");
1755
+ setTimeout(() => successMsg.remove(), 150);
1756
+ }, 3000);
1757
+ }
1758
+
1759
+ /**
1760
+ * Create the main control bar with search input and filter buttons.
1761
+ *
1762
+ * The control bar contains the primary interaction elements for filtering and searching options. It uses a responsive flexbox layout that adapts to different
1763
+ * screen sizes while maintaining usability.
1764
+ *
1765
+ * @returns {HTMLElement} The control bar element.
1766
+ * @private
1767
+ */
1768
+ #createControlBar() {
1769
+
1770
+ return this.#createElement("div", {
1771
+
1772
+ classList: ["search-toolbar"]
1773
+ }, [
1774
+ this.#createElement("div", {
1775
+
1776
+ classList: [ "d-flex", "flex-wrap", "gap-2", "align-items-center" ]
1777
+ }, [
1778
+ this.#createSearchInput(),
1779
+ this.#createFilterPills(),
1780
+ this.#createElement("div", {
1781
+
1782
+ classList: [ "ms-auto", "d-flex", "gap-2" ]
1783
+ }, [
1784
+ this.#createExpandToggle()
1785
+ ])
1786
+ ])
1787
+ ]);
1788
+ }
1789
+
1790
+ /**
1791
+ * Create the search input with Bootstrap input group styling.
1792
+ *
1793
+ * The search input uses Bootstrap's input group component for a more polished appearance. It includes proper responsive sizing and autocomplete disabled to
1794
+ * prevent browser suggestions from interfering with the search experience.
1795
+ *
1796
+ * @returns {HTMLElement} The search input wrapper.
1797
+ * @private
1798
+ */
1799
+ #createSearchInput() {
1800
+
1801
+ const searchInput = this.#createElement("input", {
1802
+
1803
+ autocomplete: "off",
1804
+ classList: ["form-control"],
1805
+ id: "searchInput",
1806
+ placeholder: "Search options...",
1807
+ type: "search"
1808
+ });
1809
+
1810
+ return this.#createElement("div", {
1811
+
1812
+ classList: [ "search-input-wrapper", "flex-grow-1" ],
1813
+ style: { maxWidth: "400px" }
1814
+ }, [
1815
+ this.#createElement("div", {
1816
+
1817
+ classList: ["input-group"]
1818
+ }, [
1819
+ searchInput
1820
+ ])
1821
+ ]);
1822
+ }
1823
+
1824
+ /**
1825
+ * Create filter pills for quick filtering of options.
1826
+ *
1827
+ * Filter pills provide a modern alternative to button groups. They're easier to tap on mobile devices and provide clearer visual separation. Currently
1828
+ * supports "All" and "Modified" filters with room for expansion.
1829
+ *
1830
+ * @returns {HTMLElement} The filter pills container.
1831
+ * @private
1832
+ */
1833
+ #createFilterPills() {
1834
+
1835
+ const filterContainer = this.#createElement("div", {
1836
+
1837
+ classList: [ "filter-pills", "d-flex", "gap-1" ]
1838
+ });
1839
+
1840
+ const filters = [
1841
+ { active: true, class: "btn-primary", id: "filter-all", text: "All", title: "Show all options." },
1842
+ { active: false, class: "btn-warning text-dark", id: "filter-modified", text: "Modified", title: "Show only modified options." }
1843
+ ];
1844
+
1845
+ for(const filter of filters) {
1846
+
1847
+ filterContainer.appendChild(this.#createFilterButton(filter));
1848
+ }
1849
+
1850
+ return filterContainer;
1851
+ }
1852
+
1853
+ /**
1854
+ * Create a filter button with proper styling and attributes.
1855
+ *
1856
+ * Each filter button represents a different view of the options. Only one filter can be active at a time, and clicking a filter immediately updates the
1857
+ * displayed options. The buttons use data attributes to store their configuration.
1858
+ *
1859
+ * @param {Object} config - The filter button configuration.
1860
+ * @param {string} config.id - The button ID.
1861
+ * @param {string} config.text - The button text.
1862
+ * @param {string} config.class - The CSS classes for active state.
1863
+ * @param {boolean} config.active - Whether this filter is initially active.
1864
+ * @param {string} config.title - The tooltip text.
1865
+ * @returns {HTMLButtonElement} The filter button element.
1866
+ * @private
1867
+ */
1868
+ #createFilterButton(config) {
1869
+
1870
+ const button = this.#createElement("button", {
1871
+
1872
+ classList: [ "btn", "btn-xs", "cursor-pointer", "user-select-none", ...(config.active ? config.class.split(" ") : ["btn-outline-secondary"]) ],
1873
+ "data-filter": config.text.toLowerCase(),
1874
+ "data-filter-type": config.filter ?? config.text.toLowerCase(),
1875
+ id: config.id,
1876
+ style: { fontSize: "0.75rem", padding: "0.125rem 0.5rem" },
1877
+ textContent: config.text,
1878
+ title: config.title,
1879
+ type: "button"
1880
+ });
1881
+
1882
+ return button;
1883
+ }
1884
+
1885
+ /**
1886
+ * Handle filter button clicks to update the active filter and refresh the display.
1887
+ *
1888
+ * When a filter is clicked, we update the visual state of all filter buttons and apply the selected filter to the option display. This provides immediate
1889
+ * feedback about what's being shown. Only one filter can be active at a time.
1890
+ *
1891
+ * @param {HTMLButtonElement} button - The clicked button.
1892
+ * @param {Object} config - The button's configuration including class and filter type.
1893
+ * @private
1894
+ */
1895
+ #handleFilterClick(button, config) {
1896
+
1897
+ // Reset all filter buttons. Only one can be active at a time.
1898
+ const filterContainer = button.parentElement;
1899
+
1900
+ for(const btn of [...filterContainer.querySelectorAll("button")]) {
1901
+
1902
+ btn.classList.remove("btn-primary", "btn-warning", "btn-info", "text-dark");
1903
+ btn.classList.add("btn-outline-secondary");
1904
+ }
1905
+
1906
+ // Apply active styling to clicked button. We restore the original classes that indicate this filter is active.
1907
+ button.classList.remove("btn-outline-secondary");
1908
+
1909
+ for(const cls of config.class.split(" ")) {
1910
+
1911
+ button.classList.add(cls);
1912
+ }
1913
+
1914
+ // Apply the filter. This updates the visibility of all option rows.
1915
+ this.#applyFilter(button.getAttribute("data-filter"));
1916
+ }
1917
+
1918
+ /**
1919
+ * Create the expand/collapse toggle button for category management.
1920
+ *
1921
+ * This button provides a quick way to expand or collapse all option categories at once. It dynamically updates its state based on whether more categories
1922
+ * are expanded or collapsed. The button shows an arrow indicator that changes direction based on its action.
1923
+ *
1924
+ * @returns {HTMLElement} The toggle button container.
1925
+ * @private
1926
+ */
1927
+ #createExpandToggle() {
1928
+
1929
+ const toggleBtn = this.#createElement("button", {
1930
+
1931
+ classList: "btn btn-xs btn-outline-secondary",
1932
+ id: "toggleAllCategories",
1933
+ style: { display: "inline-block", fontFamily: "ui-monospace", fontSize: "0.75rem", padding: "0.125rem 0.5rem", textAlign: "center" },
1934
+ type: "button"
1935
+ });
1936
+
1937
+ // Attach the update function to the button for external access. This allows other parts of the code to trigger a state update when categories change.
1938
+ toggleBtn.updateState = () => this.#updateToggleButtonState(toggleBtn);
1939
+ toggleBtn.updateState();
1940
+
1941
+ return toggleBtn;
1942
+ }
1943
+
1944
+ /**
1945
+ * Update the toggle button state based on category visibility.
1946
+ *
1947
+ * The button shows different icons and tooltips depending on whether it will expand or collapse categories when clicked. This provides clear feedback about
1948
+ * what action will be taken. The decision is based on whether more than half of the categories are currently expanded.
1949
+ *
1950
+ * @param {HTMLButtonElement} toggleBtn - The toggle button to update.
1951
+ * @private
1952
+ */
1953
+ #updateToggleButtonState(toggleBtn) {
1954
+
1955
+ const tbodies = document.querySelectorAll("#configTable tbody");
1956
+ const expandedCount = [...tbodies].filter(tbody => tbody.style.display !== "none").length;
1957
+ const shouldShowCollapse = expandedCount > (tbodies.length / 2);
1958
+
1959
+ toggleBtn.textContent = shouldShowCollapse ? "\u25B6" : "\u25BC";
1960
+ toggleBtn.title = shouldShowCollapse ? "Collapse all categories" : "Expand all categories";
1961
+ toggleBtn.setAttribute("data-action", shouldShowCollapse ? "collapse" : "expand");
1962
+ }
1963
+
1964
+ /**
1965
+ * Handle toggle button clicks to expand or collapse all categories.
1966
+ *
1967
+ * When clicked, the toggle button expands or collapses all categories based on its current state. This provides a quick way to get an overview of all
1968
+ * options or focus on specific categories. The arrow indicators in each category header are updated to match.
1969
+ *
1970
+ * @param {HTMLButtonElement} toggleBtn - The toggle button.
1971
+ * @private
1972
+ */
1973
+ #handleToggleClick(toggleBtn) {
1974
+
1975
+ const shouldExpand = toggleBtn.getAttribute("data-action") === "expand";
1976
+
1977
+ for(const tbody of [...document.querySelectorAll("#configTable tbody")]) {
1978
+
1979
+ tbody.style.display = shouldExpand ? "table-row-group" : "none";
1980
+
1981
+ const indicator = tbody.parentElement.querySelector("thead span");
1982
+
1983
+ if(indicator) {
1984
+
1985
+ indicator.textContent = shouldExpand ? "\u25BC " : "\u25B6 ";
1986
+ }
1987
+
1988
+ // Keep accessibility state synchronized for each category header.
1989
+ const headerCell = tbody.parentElement.querySelector("thead th[role='button']");
1990
+
1991
+ if(headerCell) {
1992
+
1993
+ headerCell.setAttribute("aria-expanded", shouldExpand ? "true" : "false");
1994
+ }
1995
+ }
1996
+
1997
+ toggleBtn.updateState();
1998
+ }
1999
+
2000
+ /**
2001
+ * Create option tables for each category that has valid options for the current device.
2002
+ *
2003
+ * This method creates the main content of the feature options display. Each category gets its own collapsible table containing all relevant options for the
2004
+ * current device context. Categories without valid options are skipped to keep the UI clean.
2005
+ *
2006
+ * @param {Device|undefined} currentDevice - The device to show options for.
2007
+ * @private
2008
+ */
2009
+ #createOptionTables(currentDevice) {
2010
+
2011
+ for(const category of this.#featureOptions.categories) {
2012
+
2013
+ // Skip invalid categories for this device. The UI configuration can filter out categories that don't apply to certain device types.
2014
+ if(!this.#ui.validOptionCategory(currentDevice, category)) {
2015
+
2016
+ continue;
2017
+ }
2018
+
2019
+ const optionTable = this.#createCategoryTable(category, currentDevice);
2020
+
2021
+ if(optionTable) {
2022
+
2023
+ this.#configTable.appendChild(optionTable);
2024
+ }
2025
+ }
2026
+ }
2027
+
2028
+ /**
2029
+ * Create a single category table with all its options.
2030
+ *
2031
+ * Each category table is collapsible and contains all options for that category that are valid for the current device. Options are displayed with checkboxes
2032
+ * and optional value inputs. The table is only created if there are visible options to display.
2033
+ *
2034
+ * @param {Category} category - The category to create a table for.
2035
+ * @param {Device|undefined} currentDevice - The current device context.
2036
+ * @returns {HTMLTableElement|null} The created table, or null if no options are visible.
2037
+ * @private
2038
+ */
2039
+ #createCategoryTable(category, currentDevice) {
2040
+
2041
+ // Create a unique id for the tbody so that the header can reference it for accessibility.
2042
+ const tbodyId = "tbody-" + category.name.replace(/\s+/g, "-");
2043
+
2044
+ const tbody = this.#createElement("tbody", {
2045
+
2046
+ classList: [ "border", "category-border" ],
2047
+ id: tbodyId,
2048
+ style: { display: "none" }
2049
+ });
2050
+
2051
+ let visibleOptionsCount = 0;
2052
+
2053
+ // Create rows for each option in this category. We filter out options that aren't valid for the current device context.
2054
+ for(const option of this.#featureOptions.options[category.name]) {
2055
+
2056
+ // Skip invalid options for this device. The UI configuration determines which options are appropriate for each device type.
2057
+ if(!this.#ui.validOption(currentDevice, option)) {
2058
+
2059
+ continue;
2060
+ }
2061
+
2062
+ const optionRow = this.#createOptionRow(category, option, currentDevice);
2063
+
2064
+ tbody.appendChild(optionRow);
2065
+
2066
+ // Count visible options. Grouped options might be hidden initially if their parent option is disabled.
2067
+ if(optionRow.style.display !== "none") {
2068
+
2069
+ visibleOptionsCount++;
2070
+ }
2071
+ }
2072
+
2073
+ // Don't create the table if there are no visible options. This keeps the UI clean by not showing empty categories.
2074
+ if(!visibleOptionsCount) {
2075
+
2076
+ return null;
2077
+ }
2078
+
2079
+ // Create the complete table. We use Bootstrap table classes for consistent styling with the Homebridge UI.
2080
+ const table = this.#createElement("table", {
2081
+
2082
+ classList: [ "table", "table-borderless", "table-sm", "table-hover" ],
2083
+ "data-category": category.name
2084
+ });
2085
+
2086
+ // Create and add the header. The header shows the category name and scope information.
2087
+ const thead = this.#createCategoryHeader(category, currentDevice, tbodyId);
2088
+
2089
+ table.appendChild(thead);
2090
+ table.appendChild(tbody);
2091
+
2092
+ return table;
2093
+ }
2094
+
2095
+ /**
2096
+ * Create the category header with scope indication.
2097
+ *
2098
+ * The header shows the category description and indicates the scope level (global, controller, or device). It's clickable to expand/collapse the category's
2099
+ * options. The scope label helps users understand at what level they're configuring options.
2100
+ *
2101
+ * @param {Category} category - The category information.
2102
+ * @param {Device|undefined} currentDevice - The current device context.
2103
+ * @param {string} tbodyId - The id of the tbody this header controls, for accessibility.
2104
+ * @returns {HTMLElement} The table header element.
2105
+ * @private
2106
+ */
2107
+ #createCategoryHeader(category, currentDevice, tbodyId) {
2108
+
2109
+ const categoryIndicator = this.#createElement("span", {
2110
+
2111
+ classList: "arrow",
2112
+ style: {
2113
+
2114
+ display: "inline-block",
2115
+ fontFamily: "ui-monospace",
2116
+ marginRight: "4px",
2117
+ textAlign: "center",
2118
+ width: "1ch"
2119
+ }
2120
+ }, ["\u25B6 "]);
2121
+
2122
+ const scopeLabel = !currentDevice ? " (Global)" : (this.#ui.isController(currentDevice) ? " (Controller-specific)" : " (Device-specific)");
2123
+
2124
+ const th = this.#createElement("th", {
2125
+
2126
+ "aria-controls": tbodyId,
2127
+ "aria-expanded": "false",
2128
+ classList: ["p-0"],
2129
+ colSpan: 3,
2130
+ role: "button",
2131
+ style: { cursor: "pointer", fontWeight: "bold" },
2132
+ tabIndex: 0,
2133
+ title: "Expand or collapse this category."
2134
+ }, [ categoryIndicator, category.description + scopeLabel ]);
2135
+
2136
+ const thead = this.#createElement("thead", {}, [
2137
+ this.#createElement("tr", {}, [th])
2138
+ ]);
2139
+
2140
+ return thead;
2141
+ }
2142
+
2143
+ /**
2144
+ * Create a single option row with checkbox, label, and optional value input.
2145
+ *
2146
+ * Each option row contains a checkbox, label, and optional value input. The row handles the complex three-state checkbox logic and value inheritance through
2147
+ * the scope hierarchy. Grouped options are visually distinguished and initially hidden if their parent is disabled.
2148
+ *
2149
+ * @param {Category} category - The category this option belongs to.
2150
+ * @param {Option} option - The option configuration.
2151
+ * @param {Device|undefined} currentDevice - The current device context.
2152
+ * @returns {HTMLTableRowElement} The created table row.
2153
+ * @private
2154
+ */
2155
+ #createOptionRow(category, option, currentDevice) {
2156
+
2157
+ const featureOption = this.#featureOptions.expandOption(category, option);
2158
+
2159
+ const row = this.#createElement("tr", {
2160
+
2161
+ classList: [ "align-top", ...((option.group !== undefined) ? ["grouped-option"] : []) ],
2162
+ id: "row-" + featureOption
2163
+ });
2164
+
2165
+ // Create the checkbox cell. The checkbox shows the current state and handles user interactions.
2166
+ const checkboxCell = this.#createCheckboxCell(featureOption, option, currentDevice);
2167
+
2168
+ row.appendChild(checkboxCell);
2169
+
2170
+ // Create the label and optional input cells. Value-centric options get an additional input field for entering custom values.
2171
+ const { inputCell, labelCell } = this.#createLabelCells(featureOption, option, currentDevice, checkboxCell.querySelector("input"));
2172
+
2173
+ if(inputCell) {
2174
+
2175
+ row.appendChild(inputCell);
2176
+ }
2177
+
2178
+ row.appendChild(labelCell);
2179
+
2180
+ // Hide grouped options if their parent is disabled. Grouped options depend on their parent being enabled to be meaningful.
2181
+ if((option.group !== undefined) &&
2182
+ !this.#featureOptions.test(category.name + (option.group.length ? ("." + option.group) : ""), currentDevice?.serialNumber, this.#controller)) {
2183
+
2184
+ row.style.display = "none";
2185
+ }
2186
+
2187
+ return row;
2188
+ }
2189
+
2190
+ /**
2191
+ * Create the checkbox cell for an option.
2192
+ *
2193
+ * The checkbox represents the option's state and supports three states: checked (enabled), unchecked (disabled), and indeterminate (inherited). The data
2194
+ * attributes store the device serial number for proper scope management.
2195
+ *
2196
+ * @param {string} featureOption - The expanded option name.
2197
+ * @param {Option} option - The option configuration.
2198
+ * @param {Device|undefined} currentDevice - The current device context.
2199
+ * @returns {HTMLTableCellElement} The checkbox cell.
2200
+ * @private
2201
+ */
2202
+ #createCheckboxCell(featureOption, option, currentDevice) {
2203
+
2204
+ const checkbox = this.#createElement("input", {
2205
+
2206
+ classList: "mx-2",
2207
+ "data-device-serial": currentDevice?.serialNumber ?? "",
2208
+ id: featureOption,
2209
+ name: featureOption,
2210
+ type: "checkbox",
2211
+ value: featureOption + (!currentDevice ? "" : ("." + currentDevice.serialNumber))
2212
+ });
2213
+
2214
+ // Set initial checkbox state based on scope. This determines whether the option is set at this level or inherited from a higher scope.
2215
+ this.#initializeCheckboxState(checkbox, featureOption, option, currentDevice);
2216
+
2217
+ return this.#createElement("td", {}, [checkbox]);
2218
+ }
2219
+
2220
+ /**
2221
+ * Initialize checkbox state based on option scope and inheritance.
2222
+ *
2223
+ * This method implements the complex logic for determining initial checkbox state. Options can be set at global, controller, or device scope, and lower
2224
+ * scopes inherit from higher ones unless explicitly overridden. The indeterminate state indicates inheritance from a higher scope.
2225
+ *
2226
+ * @param {HTMLInputElement} checkbox - The checkbox element.
2227
+ * @param {string} featureOption - The expanded option name.
2228
+ * @param {Option} option - The option configuration.
2229
+ * @param {Device|undefined} currentDevice - The current device context.
2230
+ * @private
2231
+ */
2232
+ #initializeCheckboxState(checkbox, featureOption, option, currentDevice) {
2233
+
2234
+ const scope = this.#featureOptions.scope(featureOption, currentDevice?.serialNumber, this.#controller);
2235
+
2236
+ switch(scope) {
2237
+
2238
+ case "global":
2239
+ case "controller":
2240
+
2241
+ if(!currentDevice) {
2242
+
2243
+ // We're at the global level - show the actual state. The indeterminate flag is explicitly set to false only when the checkbox is checked.
2244
+ checkbox.checked = this.#featureOptions.test(featureOption);
2245
+
2246
+ if(checkbox.checked) {
2247
+
2248
+ checkbox.indeterminate = false;
2249
+ }
2250
+ } else {
2251
+
2252
+ // We're at a lower level but the option is set higher up. Show the indeterminate state to indicate inheritance.
2253
+ checkbox.readOnly = checkbox.indeterminate = true;
2254
+ }
2255
+
2256
+ break;
2257
+
2258
+ case "device":
2259
+ case "none":
2260
+ default:
2261
+
2262
+ // The option is set at or below our current level. Show the actual state.
2263
+ checkbox.checked = this.#featureOptions.test(featureOption, currentDevice?.serialNumber);
2264
+
2265
+ break;
2266
+ }
2267
+
2268
+ checkbox.defaultChecked = option.default;
2269
+ }
2270
+
2271
+ /**
2272
+ * Create the label and optional input cells for an option.
2273
+ *
2274
+ * The label shows the option description and is styled based on scope. Value-centric options also get an input field for entering custom values. The layout
2275
+ * adapts based on whether a custom input size is specified, with standard-sized inputs in separate cells and custom-sized inputs inline with the label.
2276
+ *
2277
+ * @param {string} featureOption - The expanded option name.
2278
+ * @param {Option} option - The option configuration.
2279
+ * @param {Device|undefined} currentDevice - The current device context.
2280
+ * @param {HTMLInputElement} checkbox - The checkbox for this option.
2281
+ * @returns {{labelCell: HTMLTableCellElement, inputCell: HTMLTableCellElement|null}} The created cells.
2282
+ * @private
2283
+ */
2284
+ #createLabelCells(featureOption, option, currentDevice, checkbox) {
2285
+
2286
+ let inputValue = null;
2287
+ let inputCell = null;
2288
+
2289
+ // Create input field for value-centric options. These options accept a custom value in addition to being enabled/disabled.
2290
+ if(this.#featureOptions.isValue(featureOption)) {
2291
+
2292
+ const scope = this.#featureOptions.scope(featureOption, currentDevice?.serialNumber, this.#controller);
2293
+ let initialValue;
2294
+
2295
+ // Determine the initial value based on scope. We need to fetch the value from the appropriate scope level to show inherited values correctly.
2296
+ switch(scope) {
2297
+
2298
+ case "global":
2299
+ case "controller":
2300
+
2301
+ if(!currentDevice) {
2302
+
2303
+ // At global level, show the global value.
2304
+ initialValue = this.#featureOptions.value(featureOption);
2305
+ } else {
2306
+
2307
+ // At lower level, show the value from the scope where it's set.
2308
+ initialValue = this.#featureOptions.value(featureOption, (scope === "controller") ? this.#controller : undefined);
2309
+ }
2310
+
2311
+ break;
2312
+
2313
+ case "device":
2314
+ case "none":
2315
+ default:
2316
+
2317
+ // Show the device-specific value.
2318
+ initialValue = this.#featureOptions.value(featureOption, currentDevice?.serialNumber);
2319
+
2320
+ break;
2321
+ }
2322
+
2323
+ // Create the input element. We'll manage changes and updates through event delegation.
2324
+ inputValue = this.#createElement("input", {
2325
+
2326
+ classList: "form-control shadow-none",
2327
+ readOnly: !checkbox.checked,
2328
+ style: {
2329
+
2330
+ boxSizing: "content-box",
2331
+ fontFamily: "ui-monospace",
2332
+ width: (option.inputSize ?? 5) + "ch"
2333
+ },
2334
+ type: "text",
2335
+ value: initialValue ?? option.defaultValue
2336
+ });
2337
+
2338
+ // Create separate cell for standard-sized inputs. Custom-sized inputs are placed inline with the label for better layout flexibility.
2339
+ if(option.inputSize === undefined) {
2340
+
2341
+ inputCell = this.#createElement("td", {
2342
+
2343
+ classList: "mr-2",
2344
+ style: { width: "10%" }
2345
+ }, [inputValue]);
2346
+ }
2347
+ }
2348
+
2349
+ // Create the label. This shows the option description and handles click events for better UX.
2350
+ const label = this.#createOptionLabel(featureOption, option, currentDevice, checkbox);
2351
+
2352
+ const labelCell = this.#createElement("td", {
2353
+
2354
+ classList: [ "w-100", "option-label" ],
2355
+ colSpan: inputCell ? 1 : 2
2356
+ }, [
2357
+ ...((inputValue && !inputCell) ? [inputValue] : []),
2358
+ label
2359
+ ]);
2360
+
2361
+ return { inputCell, labelCell };
2362
+ }
2363
+
2364
+ /**
2365
+ * Create the option label element with proper styling and scope indication.
2366
+ *
2367
+ * The label displays the option description and is color-coded based on the option's scope. It uses the cursor-pointer class to indicate it's clickable and
2368
+ * the user-select-none class to prevent text selection during clicks.
2369
+ *
2370
+ * @param {string} featureOption - The expanded option name.
2371
+ * @param {Option} option - The option configuration.
2372
+ * @param {Device|undefined} currentDevice - The current device context.
2373
+ * @param {HTMLInputElement} checkbox - The checkbox for this option.
2374
+ * @returns {HTMLLabelElement} The label element.
2375
+ * @private
2376
+ */
2377
+ #createOptionLabel(featureOption, option, currentDevice, checkbox) {
2378
+
2379
+ const label = this.#createElement("label", {
2380
+
2381
+ classList: [ "user-select-none", "my-0", "py-0", "cursor-pointer" ],
2382
+ for: checkbox.id
2383
+ }, [option.description]);
2384
+
2385
+ // Apply scope-based coloring. This provides visual feedback about where the option's current value is coming from in the hierarchy.
2386
+ const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, currentDevice?.serialNumber ? this.#controller : undefined);
2387
+
2388
+ label.classList.add(scopeColor || "text-body");
2389
+
2390
+ return label;
2391
+ }
2392
+
2393
+ /**
2394
+ * Handle option state changes with full hierarchy and dependency management.
2395
+ *
2396
+ * This is the core method that manages all option state transitions. It handles the three-state checkbox logic, updates the configuration, manages visual
2397
+ * states, and updates dependent options. This is where the complexity of the inheritance model is implemented.
2398
+ *
2399
+ * @param {HTMLInputElement} checkbox - The checkbox that changed.
2400
+ * @param {string} featureOption - The expanded option name.
2401
+ * @param {Option} option - The option configuration.
2402
+ * @param {Device|undefined} currentDevice - The current device context.
2403
+ * @param {HTMLLabelElement} label - The option label.
2404
+ * @param {HTMLInputElement|null} inputValue - The value input for value-centric options.
2405
+ * @returns {Promise<void>}
2406
+ * @private
2407
+ */
2408
+ async #handleOptionChange(checkbox, featureOption, option, currentDevice, label, inputValue) {
2409
+
2410
+ // Remove existing option from configuration. We use a regex to match all variations of this option (Enable/Disable, with/without value).
2411
+ const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!currentDevice ? "" : ("\\." + currentDevice.serialNumber)) + "(?:\\.([^\\.]*))?$", "gi");
2412
+ const newOptions = this.#featureOptions.configuredOptions.filter(entry => !optionRegex.test(entry));
2413
+
2414
+ // Determine if option is set upstream. This affects whether we show the indeterminate state when unchecking.
2415
+ const upstreamOption = this.#hasUpstreamOption(checkbox.id, currentDevice);
2416
+
2417
+ // Handle state transitions. This implements the three-state checkbox logic for proper inheritance display.
2418
+ this.#handleCheckboxStateTransition(checkbox, upstreamOption, option, inputValue, currentDevice);
2419
+
2420
+ // Update configuration if needed. We only add configuration entries for options that differ from their defaults or have upstream settings.
2421
+ if(this.#shouldUpdateConfiguration(checkbox, option, inputValue, upstreamOption)) {
2422
+
2423
+ this.#updateOptionConfiguration(checkbox, newOptions, inputValue, featureOption);
2424
+ }
2425
+
2426
+ // Update our plugin configuration in Homebridge so they are ready to save.
2427
+ await this.#updatePluginConfiguration(newOptions);
2428
+
2429
+ // Update visual state. Options that differ from defaults are highlighted to make it easy to see what's been customized.
2430
+ this.#updateOptionVisualState(checkbox, option, label, featureOption, currentDevice);
2431
+
2432
+ // Handle dependent options. Grouped options need to be shown/hidden based on their parent option's state.
2433
+ this.#updateDependentOptions(checkbox, featureOption, currentDevice);
2434
+
2435
+ // Update our status bar with current counts.
2436
+ this.#updateCounts([...document.querySelectorAll("#configTable tr[id^='row-']")]);
2437
+ }
2438
+
2439
+ /**
2440
+ * Check if an option is set upstream in the hierarchy.
2441
+ *
2442
+ * This determines whether an option has a value set at a higher scope level. This information is used to determine whether to show the indeterminate state
2443
+ * when an option is unchecked at a lower level, indicating that it will inherit a value from above.
2444
+ *
2445
+ * @param {string} optionId - The option identifier.
2446
+ * @param {Device|undefined} currentDevice - The current device context.
2447
+ * @returns {boolean} True if the option is set at a higher scope level.
2448
+ * @private
2449
+ */
2450
+ #hasUpstreamOption(optionId, currentDevice) {
2451
+
2452
+ if(!currentDevice) {
2453
+
2454
+ return false;
2455
+ }
2456
+
2457
+ const upstreamScope = this.#featureOptions.scope(optionId, (currentDevice.serialNumber !== this.#controller) ? this.#controller : undefined);
2458
+
2459
+ switch(upstreamScope) {
2460
+
2461
+ case "device":
2462
+ case "controller":
2463
+
2464
+ return currentDevice.serialNumber !== this.#controller;
2465
+
2466
+ case "global":
2467
+ return true;
2468
+
2469
+ default:
2470
+ return false;
2471
+ }
2472
+ }
2473
+
2474
+ /**
2475
+ * Handle checkbox state transitions between checked, unchecked, and indeterminate states.
2476
+ *
2477
+ * This implements the three-state checkbox logic. Checkboxes can be checked, unchecked, or indeterminate (inherited). The transitions between these states
2478
+ * follow specific rules based on the inheritance hierarchy. Value inputs are also managed based on the checkbox state.
2479
+ *
2480
+ * @param {HTMLInputElement} checkbox - The checkbox element.
2481
+ * @param {boolean} upstreamOption - Whether the option is set upstream.
2482
+ * @param {Option} option - The option configuration.
2483
+ * @param {HTMLInputElement|null} inputValue - The value input element.
2484
+ * @param {Device|undefined} currentDevice - The current device context.
2485
+ * @private
2486
+ */
2487
+ #handleCheckboxStateTransition(checkbox, upstreamOption, option, inputValue, currentDevice) {
2488
+
2489
+ // Transitioning from indeterminate to unchecked. When the user clicks an indeterminate checkbox, it becomes unchecked, explicitly disabling the option
2490
+ // at this level.
2491
+ if(checkbox.readOnly) {
2492
+
2493
+ checkbox.checked = checkbox.readOnly = false;
2494
+
2495
+ if(inputValue) {
2496
+
2497
+ inputValue.value = option.defaultValue;
2498
+ inputValue.readOnly = true;
2499
+ inputValue.disabled = true;
2500
+ inputValue.setAttribute("aria-disabled", "true");
2501
+ }
2502
+
2503
+ return;
2504
+ }
2505
+
2506
+ // Transitioning from checked to unchecked/indeterminate. If there's an upstream value, we show indeterminate to indicate inheritance.
2507
+ if(!checkbox.checked) {
2508
+
2509
+ if(upstreamOption) {
2510
+
2511
+ checkbox.readOnly = checkbox.indeterminate = true;
2512
+ }
2513
+
2514
+ if(inputValue) {
2515
+
2516
+ const inheritedValue = this.#getInheritedValue(checkbox.id, currentDevice);
2517
+
2518
+ inputValue.value = inheritedValue ?? option.defaultValue;
2519
+ inputValue.readOnly = true;
2520
+ inputValue.disabled = true;
2521
+ inputValue.setAttribute("aria-disabled", "true");
2522
+ }
2523
+
2524
+ return;
2525
+ }
2526
+
2527
+ // Transitioning to checked. The option is explicitly enabled at this level, overriding any inherited values.
2528
+ checkbox.readOnly = checkbox.indeterminate = false;
2529
+
2530
+ if(inputValue) {
2531
+
2532
+ inputValue.readOnly = false;
2533
+ inputValue.disabled = false;
2534
+ inputValue.removeAttribute("aria-disabled");
2535
+ }
2536
+ }
2537
+
2538
+ /**
2539
+ * Get the inherited value from the scope hierarchy.
2540
+ *
2541
+ * When an option is not set at the current level, we need to look up the hierarchy to find what value it inherits. This method traverses the scopes to find
2542
+ * the inherited value, checking controller level then global level for device-specific options.
2543
+ *
2544
+ * @param {string} optionId - The option identifier.
2545
+ * @param {Device|undefined} currentDevice - The current device context.
2546
+ * @returns {*} The inherited value, or null if none exists.
2547
+ * @private
2548
+ */
2549
+ #getInheritedValue(optionId, currentDevice) {
2550
+
2551
+ if(!currentDevice?.serialNumber && !this.#controller) {
2552
+
2553
+ return null;
2554
+ }
2555
+
2556
+ if(currentDevice?.serialNumber !== this.#controller) {
2557
+
2558
+ // Device level - check controller then global. We traverse up the hierarchy looking for the first defined value.
2559
+ return this.#featureOptions.value(optionId, this.#controller) ?? this.#featureOptions.value(optionId);
2560
+ }
2561
+
2562
+ // Controller level - check global. Controllers inherit from global scope.
2563
+ return this.#featureOptions.value(optionId);
2564
+ }
2565
+
2566
+ /**
2567
+ * Check if configuration should be updated based on current state.
2568
+ *
2569
+ * We only store configuration entries for options that differ from their defaults or have upstream settings. This keeps the configuration clean and makes it
2570
+ * clear what has been customized. Indeterminate states don't need configuration entries since they inherit.
2571
+ *
2572
+ * @param {HTMLInputElement} checkbox - The checkbox element.
2573
+ * @param {Option} option - The option configuration.
2574
+ * @param {HTMLInputElement|null} inputValue - The value input element.
2575
+ * @param {boolean} upstreamOption - Whether the option is set upstream.
2576
+ * @returns {boolean} True if the configuration needs updating.
2577
+ * @private
2578
+ */
2579
+ #shouldUpdateConfiguration(checkbox, option, inputValue, upstreamOption) {
2580
+
2581
+ if(checkbox.indeterminate) {
2582
+
2583
+ return false;
2584
+ }
2585
+
2586
+ const isModified = checkbox.checked !== option.default;
2587
+ const hasValueChange = inputValue && (inputValue.value.toString() !== option.defaultValue.toString());
2588
+
2589
+ return isModified || hasValueChange || upstreamOption;
2590
+ }
2591
+
2592
+ /**
2593
+ * Update the option configuration with the current state.
2594
+ *
2595
+ * This adds the appropriate configuration entry for the option. The format depends on whether it's a simple boolean option or a value-centric option with a
2596
+ * custom value. The entry uses Enable/Disable prefixes and includes the device serial number for proper scoping.
2597
+ *
2598
+ * @param {HTMLInputElement} checkbox - The checkbox element.
2599
+ * @param {string[]} newOptions - The new options array to update.
2600
+ * @param {HTMLInputElement|null} inputValue - The value input element.
2601
+ * @param {string} featureOption - The expanded option name.
2602
+ * @private
2603
+ */
2604
+ #updateOptionConfiguration(checkbox, newOptions, inputValue, featureOption) {
2605
+
2606
+ const prefix = checkbox.checked ? "Enable." : "Disable.";
2607
+ const valueSuffix = (this.#featureOptions.isValue(featureOption) && checkbox.checked && inputValue) ? ("." + inputValue.value) : "";
2608
+
2609
+ newOptions.push(prefix + checkbox.value + valueSuffix);
2610
+ }
2611
+
2612
+ /**
2613
+ * Update the plugin configuration in Homebridge.
2614
+ *
2615
+ * This updates Homebridge with our configuration changes for the plugin. The changes are staged but not saved until the user explicitly saves the
2616
+ * configuration through the Homebridge UI.
2617
+ *
2618
+ * @param {string[]} newOptions - The updated options array.
2619
+ * @returns {Promise<void>}
2620
+ * @private
2621
+ */
2622
+ #updatePluginConfiguration(newOptions) {
2623
+
2624
+ this.currentConfig[0].options = newOptions;
2625
+ this.#featureOptions.configuredOptions = newOptions;
2626
+
2627
+ return homebridge.updatePluginConfig(this.currentConfig);
2628
+ }
2629
+
2630
+ /**
2631
+ * Update the visual state of an option based on its configuration.
2632
+ *
2633
+ * Options that differ from their defaults are highlighted with text-info to make it clear what has been customized. When an option returns to its default
2634
+ * state, we restore the scope-based coloring. This provides immediate visual feedback about option states.
2635
+ *
2636
+ * @param {HTMLInputElement} checkbox - The checkbox element.
2637
+ * @param {Option} option - The option configuration.
2638
+ * @param {HTMLLabelElement} label - The option label.
2639
+ * @param {string} featureOption - The expanded option name.
2640
+ * @param {Device|undefined} currentDevice - The current device context.
2641
+ * @private
2642
+ */
2643
+ #updateOptionVisualState(checkbox, option, label, featureOption, currentDevice) {
2644
+
2645
+ // Clear out any existing hierarchy visual indicators.
2646
+ label.classList.remove("text-body", "text-info", "text-success", "text-warning");
2647
+
2648
+ // We aren't inheriting a non-default state, and we've changed the default setting for this option in this context.
2649
+ if(!checkbox.indeterminate && (checkbox.checked !== option.default)) {
2650
+
2651
+ label.classList.add("text-info");
2652
+
2653
+ return;
2654
+ }
2655
+
2656
+ // Restore scope coloring if we're back to the default. This shows where the option's value is coming from in the hierarchy. We are set to the default in
2657
+ // this context or we've inherited a non-default state.
2658
+ if((checkbox.checked === option.default) || checkbox.indeterminate) {
2659
+
2660
+ const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serialNumber, this.#controller);
2661
+
2662
+ // If our option is set to a non-default value, we provide the visual hinting to users so they understand where in the hierarchy it's been set.
2663
+ if(scopeColor) {
2664
+
2665
+ label.classList.add(scopeColor);
2666
+
2667
+ return;
2668
+ }
2669
+ }
2670
+
2671
+ label.classList.add("text-body");
2672
+ }
2673
+
2674
+ /**
2675
+ * Update visibility of dependent options based on parent state.
2676
+ *
2677
+ * Grouped options depend on their parent option being enabled. When a parent option changes state, we need to show or hide its dependent options accordingly.
2678
+ * During search, we defer to the search handler to manage visibility to avoid conflicts.
2679
+ *
2680
+ * @param {HTMLInputElement} checkbox - The parent checkbox.
2681
+ * @param {string} featureOption - The parent option name.
2682
+ * @param {Device|undefined} currentDevice - The current device context.
2683
+ * @private
2684
+ */
2685
+ #updateDependentOptions(checkbox, featureOption, currentDevice) {
2686
+
2687
+ if(!this.#featureOptions.groups[checkbox.id]) {
2688
+
2689
+ return;
2690
+ }
2691
+
2692
+ const isEnabled = this.#featureOptions.test(featureOption, currentDevice?.serialNumber, this.#controller);
2693
+ const searchInput = document.getElementById("searchInput");
2694
+ const isSearching = searchInput?.value.trim().length > 0;
2695
+
2696
+ // Find the table that contains this checkbox to scope our search
2697
+ const currentTable = checkbox.closest("table");
2698
+
2699
+ if(!currentTable) {
2700
+
2701
+ return;
2702
+ }
2703
+
2704
+ for(const entry of this.#featureOptions.groups[checkbox.id]) {
2705
+
2706
+ // Search for the dependent row within the same table context.
2707
+ const row = currentTable.querySelector("[id='row-" + entry + "']");
2708
+
2709
+ if(!row) {
2710
+
2711
+ continue;
2712
+ }
2713
+
2714
+ // When searching, let the search handler manage visibility. We trigger a new search to update the results based on the changed state.
2715
+ if(isSearching) {
2716
+
2717
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
2718
+
2719
+ continue;
2720
+ }
2721
+
2722
+ // Update our visibility.
2723
+ row.style.display = isEnabled ? "" : "none";
2724
+ }
2725
+
2726
+ // Update our status bar.
2727
+ this.#updateCounts([...document.querySelectorAll("#configTable tr[id^='row-']")]);
2728
+ }
2729
+
2730
+ /**
2731
+ * Set up search functionality with debouncing and state management.
2732
+ *
2733
+ * The search feature allows users to quickly find specific options by typing partial matches. It includes debouncing for performance and supports various
2734
+ * keyboard shortcuts for efficiency. The actual logic for search handling and keyboard shortcuts is done through event delegation in
2735
+ * #initializeEventDelegation.
2736
+ *
2737
+ * @private
2738
+ */
2739
+ #setupSearchFunctionality() {
2740
+
2741
+ const searchInput = document.getElementById("searchInput");
2742
+
2743
+ if(!searchInput) {
2744
+
2745
+ return;
2746
+ }
2747
+
2748
+ // Get all searchable elements. We cache these references for performance since they don't change during the lifetime of the view.
2749
+ const allRows = [...document.querySelectorAll("#configTable tr[id^='row-']")];
2750
+
2751
+ // Store original visibility states. This allows us to restore the original view when search is cleared.
2752
+ const originalVisibility = new Map();
2753
+
2754
+ for(const row of allRows) {
2755
+
2756
+ originalVisibility.set(row, row.style.display);
2757
+ }
2758
+
2759
+ // Store references on the search input for use by the delegated event handler.
2760
+ searchInput._originalVisibility = originalVisibility;
2761
+
2762
+ // Calculate and display initial counts. This gives users immediate feedback about how many options are available.
2763
+ this.#updateCounts(allRows);
2764
+ }
2765
+
2766
+ /**
2767
+ * Update all count displays in the status bar.
2768
+ *
2769
+ * This updates both the search results counter and the status bar with current counts. It provides users with immediate feedback about how their actions
2770
+ * affect the visible options, including total, modified, grouped, and visible counts.
2771
+ *
2772
+ * @param {HTMLElement[]} allRows - All option rows.
2773
+ * @private
2774
+ */
2775
+ #updateCounts(allRows) {
2776
+
2777
+ const counts = this.#calculateOptionCounts(allRows);
2778
+
2779
+ this.#updateStatusBar(counts.total, counts.modified, counts.grouped, counts.visible);
2780
+ }
2781
+
2782
+ /**
2783
+ * Handle search input changes with filtering and category management.
2784
+ *
2785
+ * This is called after the debounce timeout when the user has stopped typing. It applies both the search term and any active filters to determine which
2786
+ * options should be visible. Categories with no visible options are hidden, and those with matches are auto-expanded.
2787
+ *
2788
+ * @param {string} searchTerm - The current search term.
2789
+ * @param {HTMLElement[]} allRows - All option rows.
2790
+ * @param {HTMLElement[]} allTables - All category tables.
2791
+ * @param {Map<HTMLElement, string>} originalVisibility - Original visibility states.
2792
+ * @private
2793
+ */
2794
+ #handleSearch(searchTerm, allRows, allTables, originalVisibility) {
2795
+
2796
+ const term = searchTerm.toLowerCase();
2797
+ const activeFilter = this.#getActiveFilter();
2798
+
2799
+ // Apply search and filter to each row. We combine both criteria to determine final visibility.
2800
+ for(const row of allRows) {
2801
+
2802
+ this.#updateRowVisibility(row, this.#shouldShowRow(row, term, activeFilter, originalVisibility), term, activeFilter, originalVisibility);
2803
+ }
2804
+
2805
+ // Update counts and category visibility. Empty categories are hidden and categories with matches are expanded.
2806
+ this.#updateCounts(allRows);
2807
+ this.#updateCategoryVisibility(allTables, searchTerm);
2808
+ document.getElementById("toggleAllCategories")?.updateState?.();
2809
+ }
2810
+
2811
+ /**
2812
+ * Get the currently active filter from the filter pills.
2813
+ *
2814
+ * Filters and search work together - the active filter determines the base set of options, and search further refines within that set. The active filter is
2815
+ * identified by not having the btn-outline-secondary class.
2816
+ *
2817
+ * @returns {string} The active filter type.
2818
+ * @private
2819
+ */
2820
+ #getActiveFilter() {
2821
+
2822
+ // Return the active filter from the currently selected pill, or "all" if none is selected.
2823
+ return (document.querySelector(".filter-pills button:not(.btn-outline-secondary)")?.dataset.filter) ?? "all";
2824
+ }
2825
+
2826
+ /**
2827
+ * Check if a row should be shown based on search and filter criteria.
2828
+ *
2829
+ * A row must match both the search term and the active filter to be shown. When neither search nor filter is active, we restore original visibility. This
2830
+ * allows proper handling of grouped options that are conditionally visible.
2831
+ *
2832
+ * @param {HTMLElement} row - The option row.
2833
+ * @param {string} searchTerm - The current search term.
2834
+ * @param {string} filter - The active filter.
2835
+ * @param {Map<HTMLElement, string>} originalVisibility - Original visibility states.
2836
+ * @returns {boolean} True if the row should be visible.
2837
+ * @private
2838
+ */
2839
+ #shouldShowRow(row, searchTerm, filter, originalVisibility) {
2840
+
2841
+ if(!searchTerm && (filter === "all")) {
2842
+
2843
+ return originalVisibility.get(row) !== "none";
2844
+ }
2845
+
2846
+ const matchesSearch = !searchTerm || row.querySelector("label")?.textContent.toLowerCase().includes(searchTerm);
2847
+ const matchesFilter = this.#rowMatchesFilter(row, filter);
2848
+
2849
+ return matchesSearch && matchesFilter;
2850
+ }
2851
+
2852
+ /**
2853
+ * Update row visibility based on search/filter results with dependency handling.
2854
+ *
2855
+ * This handles the visual updates when search or filter changes. It includes special handling for grouped options that need dependency indicators when their
2856
+ * parent is disabled. Grouped options show visual hints about their dependencies during search.
2857
+ *
2858
+ * @param {HTMLElement} row - The option row.
2859
+ * @param {boolean} shouldShow - Whether the row should be visible.
2860
+ * @param {string} searchTerm - The current search term.
2861
+ * @param {string} filter - The active filter.
2862
+ * @param {Map<HTMLElement, string>} originalVisibility - Original visibility states.
2863
+ * @private
2864
+ */
2865
+ #updateRowVisibility(row, shouldShow, searchTerm, filter, originalVisibility) {
2866
+
2867
+ if(!searchTerm && (filter === "all")) {
2868
+
2869
+ // Restore original state. This includes clearing any temporary modifications made during search.
2870
+ row.style.display = originalVisibility.get(row);
2871
+ row.style.opacity = "";
2872
+ this.#resetRowState(row);
2873
+ } else if(shouldShow) {
2874
+
2875
+ row.style.display = "";
2876
+
2877
+ if(row.classList.contains("grouped-option")) {
2878
+
2879
+ // Grouped options need special handling to show when they're disabled due to their parent being off.
2880
+ const label = row.querySelector("label");
2881
+
2882
+ this.#handleGroupedOption(row, label);
2883
+ } else {
2884
+
2885
+ row.style.opacity = "";
2886
+ this.#resetRowState(row);
2887
+ }
2888
+ } else {
2889
+
2890
+ row.style.display = "none";
2891
+ }
2892
+ }
2893
+
2894
+ /**
2895
+ * Reset row state after search/filter changes.
2896
+ *
2897
+ * When search is cleared or filters change, we need to remove any temporary modifications made to show dependency states or disable checkboxes. This ensures
2898
+ * the UI returns to its normal interactive state.
2899
+ *
2900
+ * @param {HTMLElement} row - The option row to reset.
2901
+ * @private
2902
+ */
2903
+ #resetRowState(row) {
2904
+
2905
+ const checkbox = row.querySelector("input[type='checkbox']");
2906
+
2907
+ if(checkbox?.dataset.searchDisabled) {
2908
+
2909
+ checkbox.disabled = false;
2910
+ delete checkbox.dataset.searchDisabled;
2911
+ checkbox.title = "";
2912
+ }
2913
+
2914
+ const indicator = row.querySelector(".dependency-indicator");
2915
+
2916
+ if(indicator) {
2917
+
2918
+ indicator.remove();
2919
+ }
2920
+ }
2921
+
2922
+ /**
2923
+ * Default device information panel handler that displays device metadata.
2924
+ *
2925
+ * This shows device information in the stats panel using a responsive grid layout. Plugins can override this to show additional device-specific information.
2926
+ * The grid automatically hides less important fields on smaller screens.
2927
+ *
2928
+ * @param {Device|undefined} device - The device to show information for.
2929
+ * @private
2930
+ */
2931
+ #showDeviceInfoPanel(device) {
2932
+
2933
+ if(!device) {
2934
+
2935
+ this.#deviceStatsContainer.textContent = "";
2936
+
2937
+ return;
2938
+ }
2939
+
2940
+ // Create a grid layout for device stats. This provides better responsiveness than the previous table layout.
2941
+ this.#deviceStatsContainer.innerHTML =
2942
+ "<div class=\"device-stats-grid\">" +
2943
+ "<div class=\"stat-item\">" +
2944
+ "<span class=\"stat-label\">Firmware</span>" +
2945
+ "<span class=\"stat-value\">" + (device.firmwareRevision ?? "N/A") + "</span>" +
2946
+ "</div>" +
2947
+ "<div class=\"stat-item\">" +
2948
+ "<span class=\"stat-label\">Serial Number</span>" +
2949
+ "<span class=\"stat-value\">" + (device.serialNumber ?? "N/A") + "</span>" +
2950
+ "</div>" +
2951
+ "<div class=\"stat-item\">" +
2952
+ "<span class=\"stat-label\">Model</span>" +
2953
+ "<span class=\"stat-value\">" + (device.model ?? "N/A") + "</span>" +
2954
+ "</div>" +
2955
+ "<div class=\"stat-item\">" +
2956
+ "<span class=\"stat-label\">Manufacturer</span>" +
2957
+ "<span class=\"stat-value\">" + (device.manufacturer ?? "N/A") + "</span>" +
2958
+ "</div>" +
2959
+ "</div>";
2960
+ }
2961
+
2962
+ /**
2963
+ * Default method for enumerating the device list in the sidebar with optional grouping.
2964
+ *
2965
+ * This creates the device entries in the sidebar navigation. Each device gets a clickable entry that shows its feature options when selected. Devices can be
2966
+ * organized into groups using the sidebarGroup property, with "hidden" being a reserved group name for devices that shouldn't appear.
2967
+ *
2968
+ * @private
2969
+ */
2970
+ #showSidebarDevices() {
2971
+
2972
+ // If we have no devices, there's nothing to display.
2973
+ if(!this.#devices?.length) {
2974
+
2975
+ return;
750
2976
  }
751
2977
 
752
- // Create a row for this device category.
753
- const trCategory = document.createElement("tr");
2978
+ // Helper function to create and append a device link.
2979
+ const appendDeviceLink = (device) => {
2980
+
2981
+ const link = this.#createElement("a", {
2982
+
2983
+ classList: [ "nav-link", "text-decoration-none" ],
2984
+ "data-navigation": "device",
2985
+ href: "#",
2986
+ name: device.serialNumber,
2987
+ role: "button"
2988
+ }, [device.name ?? "Unknown"]);
754
2989
 
755
- // Disable any pointer events and hover activity.
756
- trCategory.style.pointerEvents = "none";
2990
+ this.#devicesContainer.appendChild(link);
2991
+ };
2992
+
2993
+ // Create a group header element and append it to the container.
2994
+ const appendGroupHeader = (label) => {
757
2995
 
758
- // Create the cell for our device category row.
759
- const tdCategory = document.createElement("td");
2996
+ const header = this.#createElement("h6", {
760
2997
 
761
- tdCategory.classList.add("m-0", "p-0", "pl-1", "w-100");
2998
+ classList: [ "nav-header", "text-muted", "text-uppercase", "small", "mb-1" ]
2999
+ }, [label]);
762
3000
 
763
- // Add the category name, with appropriate casing.
764
- tdCategory.appendChild(document.createTextNode(this.#sidebar.deviceLabel));
765
- tdCategory.style.fontWeight = "bold";
3001
+ this.#devicesContainer.appendChild(header);
3002
+ };
766
3003
 
767
- // Add the cell to the table row.
768
- trCategory.appendChild(tdCategory);
3004
+ // Create the top-level device label, if configured, to visually group all devices.
3005
+ if(this.#sidebar.deviceLabel) {
769
3006
 
770
- // Add the table row to the table.
771
- this.devicesTable.appendChild(trCategory);
3007
+ appendGroupHeader(this.#sidebar.deviceLabel);
3008
+ }
772
3009
 
3010
+ // Display ungrouped devices first, by convention.
773
3011
  for(const device of this.#devices) {
774
3012
 
775
- // Create a row for this device.
776
- const trDevice = document.createElement("tr");
3013
+ if(!device.sidebarGroup) {
3014
+
3015
+ appendDeviceLink(device);
3016
+ }
3017
+ }
3018
+
3019
+ // Determine all valid sidebar groups, excluding controllers and the reserved "hidden" group.
3020
+ const groups = [...new Set(
3021
+ this.#devices
3022
+ .filter(device => !this.#ui.isController(device) && device.sidebarGroup && (device.sidebarGroup !== "hidden"))
3023
+ .map(device => device.sidebarGroup)
3024
+ )].sort();
3025
+
3026
+ // Display devices by group with headers.
3027
+ for(const group of groups) {
3028
+
3029
+ appendGroupHeader(group);
3030
+
3031
+ for(const device of this.#devices) {
3032
+
3033
+ if(device.sidebarGroup === group) {
777
3034
 
778
- trDevice.classList.add("m-0", "p-0");
3035
+ appendDeviceLink(device);
3036
+ }
3037
+ }
3038
+ }
3039
+ }
3040
+
3041
+ /**
3042
+ * Default method for retrieving the device list from the Homebridge accessory cache.
3043
+ *
3044
+ * This reads devices from Homebridge's cached accessories, extracting the relevant information for display. Plugins can override this to provide devices from
3045
+ * other sources like network controllers. The method returns a sorted list of devices with their metadata.
3046
+ *
3047
+ * @returns {Promise<Device[]>} The list of devices sorted alphabetically by name.
3048
+ * @public
3049
+ */
3050
+ async getHomebridgeDevices() {
3051
+
3052
+ // Retrieve and map the cached accessories to our device format. We extract the specific characteristics we need from the AccessoryInformation service.
3053
+ const cachedAccessories = await homebridge.getCachedAccessories();
3054
+ const devices = [];
3055
+
3056
+ for(const device of cachedAccessories) {
3057
+
3058
+ const info = device.services.find(s => s.constructorName === "AccessoryInformation");
3059
+ const getCharValue = (name) => info?.characteristics.find(c => c.constructorName === name)?.value ?? "";
3060
+
3061
+ devices.push({
3062
+
3063
+ firmwareRevision: getCharValue("FirmwareRevision"),
3064
+ manufacturer: getCharValue("Manufacturer"),
3065
+ model: getCharValue("Model"),
3066
+ name: device.displayName,
3067
+ serialNumber: getCharValue("SerialNumber")
3068
+ });
3069
+ }
3070
+
3071
+ // Sort devices alphabetically by name. This provides a consistent, user-friendly ordering in the sidebar.
3072
+ return devices.sort((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase()));
3073
+ }
3074
+
3075
+ /**
3076
+ * Apply a filter to show only options matching the specified criteria.
3077
+ *
3078
+ * Filters provide quick ways to focus on specific subsets of options. This is particularly useful for reviewing what has been modified or finding specific
3079
+ * types of options. The filter works in conjunction with search for refined results.
3080
+ *
3081
+ * @param {string} filterType - The type of filter to apply (all, modified, or grouped).
3082
+ * @public
3083
+ */
3084
+ #applyFilter(filterType) {
3085
+
3086
+ const allRows = [...document.querySelectorAll("#configTable tr[id^='row-']")];
3087
+ const allTables = [...document.querySelectorAll("#configTable table")];
3088
+
3089
+ // Resolve the active device context so we can evaluate parent state via the model rather than the DOM.
3090
+ const deviceSerial = this.#getCurrentDeviceSerial();
3091
+
3092
+ // Apply the filter to each row. The filter determines the base visibility before any search is applied.
3093
+ for(const row of allRows) {
3094
+
3095
+ // For grouped options, we need to check if their parent is enabled even when showing "all".
3096
+ if(row.classList.contains("grouped-option") && (filterType === "all")) {
3097
+
3098
+ const checkbox = row.querySelector("input[type='checkbox']");
3099
+
3100
+ if(checkbox) {
3101
+
3102
+ // Find the parent option that controls this grouped option.
3103
+ const parentId = Object.keys(this.#featureOptions.groups).find(key => this.#featureOptions.groups[key].includes(checkbox.id));
3104
+ const isParentEnabled = parentId ? this.#featureOptions.test(parentId, deviceSerial, this.#controller) : true;
3105
+
3106
+ // Only show the grouped option if it matches the filter AND its parent is enabled by the model.
3107
+ row.style.display = (this.#rowMatchesFilter(row, filterType) && isParentEnabled) ? "" : "none";
3108
+ } else {
3109
+
3110
+ row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
3111
+ }
3112
+ } else {
3113
+
3114
+ row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
3115
+ }
3116
+ }
3117
+
3118
+ // Update counts and visibility. We need to update various UI elements to reflect the filtered view.
3119
+ this.#updateCounts(allRows);
3120
+ this.#updateCategoryVisibility(allTables, "");
3121
+ document.getElementById("toggleAllCategories")?.updateState?.();
3122
+
3123
+ // Reapply search if active. Search works within the filtered set, so we need to rerun it when the filter changes.
3124
+ const searchInput = document.getElementById("searchInput");
3125
+
3126
+ if(searchInput?.value) {
3127
+
3128
+ searchInput.dispatchEvent(new Event("input", { bubbles: true }));
3129
+ }
3130
+ }
3131
+
3132
+ /**
3133
+ * Check if a row matches the specified filter criteria.
3134
+ *
3135
+ * Each filter has specific criteria for what rows it includes. This method centralizes the filter logic for consistent behavior. Currently supports "all"
3136
+ * and "modified" filters with room for expansion.
3137
+ *
3138
+ * @param {HTMLElement} row - The table row element to check.
3139
+ * @param {string} filterType - The filter type to match against.
3140
+ * @returns {boolean} True if the row matches the filter.
3141
+ * @public
3142
+ */
3143
+ #rowMatchesFilter(row, filterType) {
3144
+
3145
+ switch(filterType) {
779
3146
 
780
- // Create a cell for our device.
781
- const tdDevice = document.createElement("td");
3147
+ case "modified":
3148
+ // Modified options have the text-info class to indicate they differ from defaults.
3149
+ return row.querySelector("label")?.classList.contains("text-info") ?? false;
782
3150
 
783
- tdDevice.classList.add("m-0", "p-0", "w-100");
3151
+ case "all":
3152
+ default:
3153
+ return true;
3154
+ }
3155
+ }
3156
+
3157
+ /**
3158
+ * Calculate counts for all option states.
3159
+ *
3160
+ * This provides the statistics shown in the status bar. We count total options, how many are modified, grouped, and currently visible based on
3161
+ * filters/search. These counts help users understand the scope of their configuration.
3162
+ *
3163
+ * @param {NodeList|Array} allRows - All option rows to count.
3164
+ * @returns {{total: number, modified: number, grouped: number, visible: number}} Count statistics.
3165
+ * @public
3166
+ */
3167
+ #calculateOptionCounts(allRows) {
3168
+
3169
+ return [...allRows].reduce((counts, row) => {
784
3170
 
785
- const label = document.createElement("label");
3171
+ counts.total++;
786
3172
 
787
- label.name = device.serialNumber;
788
- label.appendChild(document.createTextNode(device.name ?? "Unknown"));
789
- label.style.cursor = "pointer";
790
- label.classList.add("mx-2", "my-0", "p-0", "w-100");
3173
+ if(row.style.display !== "none") {
3174
+
3175
+ counts.visible++;
3176
+ }
3177
+
3178
+ if(row.querySelector("label")?.classList.contains("text-info")) {
3179
+
3180
+ counts.modified++;
3181
+ }
3182
+
3183
+ if(row.classList.contains("grouped-option")) {
3184
+
3185
+ counts.grouped++;
3186
+ }
791
3187
 
792
- label.addEventListener("click", () => this.showDeviceOptions(device.serialNumber));
3188
+ return counts;
3189
+ }, { grouped: 0, modified: 0, total: 0, visible: 0 });
3190
+ }
793
3191
 
794
- // Add the device label to our cell.
795
- tdDevice.appendChild(label);
3192
+ /**
3193
+ * Update the status bar with current option counts and scope indication.
3194
+ *
3195
+ * The status bar provides at-a-glance information about the current view. It's updated whenever search, filters, or option states change. The scope label
3196
+ * indicates whether we're viewing global, controller, or device options.
3197
+ *
3198
+ * @param {number} total - Total number of options.
3199
+ * @param {number} modified - Number of modified options.
3200
+ * @param {number} grouped - Number of grouped options.
3201
+ * @param {number} visible - Number of currently visible options.
3202
+ * @public
3203
+ */
3204
+ #updateStatusBar(total, modified, grouped, visible) {
796
3205
 
797
- // Add the cell to the table row.
798
- trDevice.appendChild(tdDevice);
3206
+ const statusInfo = document.getElementById("statusInfo");
799
3207
 
800
- // Add the table row to the table.
801
- this.devicesTable.appendChild(trDevice);
3208
+ if(!statusInfo) {
802
3209
 
803
- this.webUiDeviceList.push(label);
3210
+ return;
804
3211
  }
3212
+
3213
+ // Check for device-level scope before checking for controller and global scope.
3214
+ const scope = this.#devicesContainer.querySelector("a[data-navigation='device'].active")?.dataset?.navigation ??
3215
+ this.#controllersContainer.querySelector(".nav-link.active[data-navigation]")?.dataset?.navigation ?? "unknown";
3216
+
3217
+ statusInfo.style.whiteSpace = "nowrap";
3218
+ statusInfo.innerHTML = "<span class=\"text-muted\"><strong>" + total + " " + scope + " options \u00B7 " +
3219
+ "<span class=\"text-info\">" + modified + "</span> modified \u00B7 " + grouped + " grouped \u00B7 " + visible + " visible" + "</strong></span>";
805
3220
  }
806
3221
 
807
- // Default method for retrieving the device list from the Homebridge accessory cache.
808
- async #getHomebridgeDevices() {
3222
+ /**
3223
+ * Reset all options to their default values.
3224
+ *
3225
+ * This provides a quick way to return to a clean configuration. It's a destructive action that clears all customization. After reset, the UI is refreshed to
3226
+ * show the clean state with all options at their defaults.
3227
+ *
3228
+ * @returns {Promise<void>}
3229
+ * @public
3230
+ */
3231
+ async #resetAllOptions() {
3232
+
3233
+ homebridge.showSpinner();
3234
+
3235
+ // Clear all configured options. An empty options array means everything returns to defaults.
3236
+ this.currentConfig[0].options = [];
3237
+ this.#featureOptions.configuredOptions = [];
3238
+
3239
+ // Update the configuration in Homebridge. This persists the reset.
3240
+ await homebridge.updatePluginConfig(this.currentConfig);
809
3241
 
810
- // Retrieve the full list of cached accessories.
811
- let devices = await homebridge.getCachedAccessories();
3242
+ // Find the currently selected device. We want to maintain the user's context after the reset.
3243
+ const selectedDevice = this.#devicesContainer.querySelector("a[data-navigation='device'].active");
812
3244
 
813
- // Filter out only the components we're interested in.
814
- devices = devices.map(device => ({
3245
+ // Refresh the UI to show the reset state. All options will now show their default values.
3246
+ this.#showDeviceOptions(selectedDevice?.name ?? "Global Options");
815
3247
 
816
- firmwareRevision: (device.services.find(service => service.constructorName ===
817
- "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "FirmwareRevision")?.value ?? ""),
818
- manufacturer: (device.services.find(service => service.constructorName ===
819
- "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "Manufacturer")?.value ?? ""),
820
- model: (device.services.find(service => service.constructorName ===
821
- "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "Model")?.value ?? ""),
822
- name: device.displayName,
823
- serialNumber: (device.services.find(service => service.constructorName ===
824
- "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "SerialNumber")?.value ?? "")
825
- }));
3248
+ homebridge.hideSpinner();
3249
+
3250
+ // Show a success message. This confirms the action completed successfully.
3251
+ this.#showResetSuccessMessage();
3252
+ }
3253
+
3254
+ /**
3255
+ * Show a success message after resetting to defaults.
3256
+ *
3257
+ * This provides positive feedback that the reset action completed successfully. The message auto-dismisses after a few seconds to avoid cluttering the UI.
3258
+ * Uses Bootstrap's alert component for consistent styling.
3259
+ *
3260
+ * @private
3261
+ */
3262
+ #showResetSuccessMessage() {
826
3263
 
827
- // Sort it for posterity.
828
- devices.sort((a, b) => {
3264
+ const statusBar = document.getElementById("featureStatusBar");
829
3265
 
830
- const aCase = (a.name ?? "").toLowerCase();
831
- const bCase = (b.name ?? "").toLowerCase();
3266
+ if(!statusBar) {
832
3267
 
833
- return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
3268
+ return;
3269
+ }
3270
+
3271
+ const successMsg = this.#createElement("div", {
3272
+
3273
+ classList: "alert alert-success alert-dismissible fade show mt-2",
3274
+ innerHTML: "<strong>All options have been reset to their default values.</strong>" +
3275
+ "<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\" aria-label=\"Close\"></button>",
3276
+ role: "alert"
834
3277
  });
835
3278
 
836
- // Return the list.
837
- return devices;
3279
+ statusBar.insertAdjacentElement("afterend", successMsg);
3280
+
3281
+ // Auto-dismiss after 3 seconds. This keeps the UI clean while still providing sufficient time to read the message.
3282
+ setTimeout(() => {
3283
+
3284
+ successMsg.classList.remove("show");
3285
+ setTimeout(() => successMsg.remove(), 150);
3286
+ }, 3000);
3287
+ }
3288
+
3289
+ /**
3290
+ * Handle the special display requirements for grouped options during search.
3291
+ *
3292
+ * Grouped options have dependencies on parent options. When searching, we need to show these dependencies clearly even when the parent option might be
3293
+ * filtered out. This method handles the visual indicators and state management, adding badges and disabling checkboxes as needed.
3294
+ *
3295
+ * @param {HTMLElement} row - The table row element.
3296
+ * @param {HTMLElement} label - The label element within the row.
3297
+ * @returns {{isDependent: boolean, isParentEnabled: boolean}} Dependency state information.
3298
+ * @private
3299
+ */
3300
+ #handleGroupedOption(row, label) {
3301
+
3302
+ const checkbox = row.querySelector("input[type='checkbox']");
3303
+
3304
+ if(!checkbox) {
3305
+
3306
+ return { isDependent: false, isParentEnabled: false };
3307
+ }
3308
+
3309
+ // Find the parent that controls this option. We look through the groups mapping to find which parent controls this child option.
3310
+ const parentId = Object.keys(this.#featureOptions.groups).find(key => this.#featureOptions.groups[key].includes(checkbox.id));
3311
+
3312
+ // Evaluate the parent state using the model so that inherited/indeterminate states are handled correctly.
3313
+ const deviceSerial = this.#getCurrentDeviceSerial();
3314
+ const isParentEnabled = parentId ? this.#featureOptions.test(parentId, deviceSerial, this.#controller) : true;
3315
+
3316
+ if(!isParentEnabled) {
3317
+
3318
+ // Parent disabled - show as unavailable. We use visual indicators to make it clear why this option can't be enabled.
3319
+ row.style.opacity = "0.5";
3320
+ checkbox.disabled = true;
3321
+ checkbox.title = "Parent option must be enabled first";
3322
+ checkbox.dataset.searchDisabled = "true";
3323
+
3324
+ // Add dependency indicator if needed. This badge makes the dependency clear even when the parent is filtered out.
3325
+ if(!label?.querySelector(".dependency-indicator")) {
3326
+
3327
+ const indicator = this.#createElement("span", {
3328
+
3329
+ classList: "dependency-indicator badge bg-warning text-dark ms-2",
3330
+ style: { fontSize: "0.75em" },
3331
+ textContent: "requires parent"
3332
+ });
3333
+
3334
+ label?.appendChild(indicator);
3335
+ }
3336
+
3337
+ return { isDependent: true, isParentEnabled: false };
3338
+ }
3339
+
3340
+ // Parent enabled - fully available. Remove any dependency indicators since the option can now be toggled freely.
3341
+ row.style.opacity = "";
3342
+
3343
+ if(checkbox.dataset.searchDisabled) {
3344
+
3345
+ checkbox.disabled = false;
3346
+ delete checkbox.dataset.searchDisabled;
3347
+ checkbox.title = "";
3348
+ }
3349
+
3350
+ // Remove any dependency indicator.
3351
+ label?.querySelector(".dependency-indicator")?.remove();
3352
+
3353
+ return { isDependent: false, isParentEnabled: true };
3354
+ }
3355
+
3356
+ /**
3357
+ * Update category visibility based on search results.
3358
+ *
3359
+ * During search, we hide categories that have no matching options and automatically expand categories that do have matches. This helps users quickly see all
3360
+ * matching options without manually expanding categories. Empty categories are completely hidden to reduce visual clutter.
3361
+ *
3362
+ * @param {NodeList|Array} allTables - All category tables.
3363
+ * @param {string} searchTerm - The current search term.
3364
+ * @public
3365
+ */
3366
+ #updateCategoryVisibility(allTables, searchTerm) {
3367
+
3368
+ for(const table of [...allTables]) {
3369
+
3370
+ const tbody = table.querySelector("tbody");
3371
+
3372
+ if(!tbody) {
3373
+
3374
+ continue;
3375
+ }
3376
+
3377
+ // Check if any rows are visible. A category with no visible options should be hidden entirely.
3378
+ const hasVisible = [...tbody.querySelectorAll("tr")].some(row => row.style.display !== "none");
3379
+
3380
+ // Hide empty categories. This keeps the UI clean during filtered views.
3381
+ table.style.display = hasVisible ? "" : "none";
3382
+
3383
+ // Auto-expand categories with search matches. This ensures users see all matching options without having to manually expand each category.
3384
+ if(searchTerm && hasVisible && (tbody.style.display === "none")) {
3385
+
3386
+ tbody.style.display = "table-row-group";
3387
+
3388
+ const indicator = table.querySelector("thead span");
3389
+
3390
+ if(indicator) {
3391
+
3392
+ indicator.textContent = "\u25BC ";
3393
+ }
3394
+
3395
+ // Ensure accessibility state reflects the open state.
3396
+ const headerCell = table.querySelector("thead th[role='button']");
3397
+
3398
+ if(headerCell) {
3399
+
3400
+ headerCell.setAttribute("aria-expanded", "true");
3401
+ }
3402
+ }
3403
+ }
3404
+ }
3405
+
3406
+ /**
3407
+ * Clean up all resources when the instance is no longer needed.
3408
+ *
3409
+ * This should be called before creating a new instance or when navigating away from the feature options view. It ensures all event listeners are removed and
3410
+ * resources are freed to prevent memory leaks. The search input is also cleared to reset any pending timeouts.
3411
+ *
3412
+ * @public
3413
+ */
3414
+ cleanup() {
3415
+
3416
+ this.#cleanupEventListeners();
3417
+ this.#eventListeners.clear();
3418
+
3419
+ // Clear any pending timeouts from search debouncing. This prevents the timeout from firing after the instance is destroyed.
3420
+ const searchInput = document.getElementById("searchInput");
3421
+
3422
+ if(searchInput) {
3423
+
3424
+ if(searchInput._searchTimeout) {
3425
+
3426
+ clearTimeout(searchInput._searchTimeout);
3427
+ searchInput._searchTimeout = null;
3428
+ }
3429
+
3430
+ searchInput.value = "";
3431
+ }
3432
+ }
3433
+
3434
+ /**
3435
+ * Get the currently selected device serial number from the UI.
3436
+ *
3437
+ * This helper centralizes how we determine the active device context, which is useful for model-based checks during search and filtering.
3438
+ *
3439
+ * @returns {string|null} The active device serial or null for global context.
3440
+ * @private
3441
+ */
3442
+ #getCurrentDeviceSerial() {
3443
+
3444
+ const activeDeviceLink = this.#devicesContainer.querySelector("a[data-navigation='device'].active");
3445
+
3446
+ return activeDeviceLink?.name ?? null;
3447
+ }
3448
+
3449
+ /**
3450
+ * Compare two arrays of strings for set-wise equality.
3451
+ *
3452
+ * This allows us to decide whether the saved snapshot should be updated when loaded plugin config changes order or content.
3453
+ *
3454
+ * @param {string[]} a - First array.
3455
+ * @param {string[]} b - Second array.
3456
+ * @returns {boolean} True if both arrays contain the same strings (order-insensitive).
3457
+ * @private
3458
+ */
3459
+ #sameStringArray(a, b) {
3460
+
3461
+ if(a === b) {
3462
+
3463
+ return true;
3464
+ }
3465
+
3466
+ if(!Array.isArray(a) || !Array.isArray(b)) {
3467
+
3468
+ return false;
3469
+ }
3470
+
3471
+ if(a.length !== b.length) {
3472
+
3473
+ return false;
3474
+ }
3475
+
3476
+ // We compare order-insensitively by sorting shallow copies.
3477
+ const aa = [...a].sort();
3478
+ const bb = [...b].sort();
3479
+
3480
+ for(let i = 0; i < aa.length; i++) {
3481
+
3482
+ if(aa[i] !== bb[i]) {
3483
+
3484
+ return false;
3485
+ }
3486
+ }
3487
+
3488
+ return true;
838
3489
  }
839
3490
  }