homebridge-unifi-access 1.2.2 → 1.4.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.
@@ -0,0 +1,836 @@
1
+ /* Copyright(C) 2017-2024, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * webUi-featureoptions.mjs: Device feature option webUI.
4
+ */
5
+ "use strict";
6
+
7
+ import { FeatureOptions} from "./featureoptions.js";
8
+
9
+ export class webUiFeatureOptions {
10
+
11
+ // Table containing the currently displayed feature options.
12
+ #configTable;
13
+
14
+ // The current controller context.
15
+ #controller;
16
+
17
+ // The current plugin configuration.
18
+ currentConfig;
19
+
20
+ // Table containing the details on the currently selected device.
21
+ deviceStatsTable;
22
+
23
+ // Current list of devices from the Homebridge accessory cache.
24
+ #devices;
25
+
26
+ // Table containing the list of devices.
27
+ devicesTable;
28
+
29
+ // Feature options instance.
30
+ #featureOptions;
31
+
32
+ // Get devices handler.
33
+ #getDevices;
34
+
35
+ // Enable the use of controllers.
36
+ #hasControllers;
37
+
38
+ // Device information panel handler.
39
+ #infoPanel;
40
+
41
+ // Sidebar configuration parameters.
42
+ #sidebar;
43
+
44
+ // Options UI configuration parameters.
45
+ #ui;
46
+
47
+ // Current list of controllers, for webUI elements.
48
+ webUiControllerList;
49
+
50
+ // Current list of devices on a given controller, for webUI elements.
51
+ webUiDeviceList;
52
+
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 = {}) {
69
+
70
+ // Defaults for the feature option webUI sidebar.
71
+ this.#ui = {
72
+
73
+ isController: () => false,
74
+ validOption: () => true,
75
+ validOptionCategory: () => true
76
+ };
77
+
78
+ // Defaults for the feature option webUI sidebar.
79
+ this.#sidebar = {
80
+
81
+ controllerLabel: "Controllers",
82
+ deviceLabel: "Devices",
83
+ showDevices: this.#showSidebarDevices.bind(this)
84
+ };
85
+
86
+ // Defaults for the feature option webUI.
87
+ const {
88
+
89
+ getDevices = this.#getHomebridgeDevices,
90
+ hasControllers = true,
91
+ infoPanel = this.#showDeviceInfoPanel,
92
+ sidebar = {},
93
+ ui = {}
94
+ } = options;
95
+
96
+ this.#configTable = document.getElementById("configTable");
97
+ this.#controller = null;
98
+ this.currentConfig = [];
99
+ this.deviceStatsTable = document.getElementById("deviceStatsTable");
100
+ this.#devices = [];
101
+ this.devicesTable = document.getElementById("devicesTable");
102
+ this.#featureOptions = null;
103
+ this.#getDevices = getDevices;
104
+ this.#hasControllers = hasControllers;
105
+ 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 = [];
110
+ }
111
+
112
+ /**
113
+ * Render the feature options webUI.
114
+ */
115
+ async show() {
116
+
117
+ // Show the beachball while we setup.
118
+ homebridge.showSpinner();
119
+ homebridge.hideSchemaForm();
120
+
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";
132
+
133
+ // Make sure we have the refreshed configuration.
134
+ this.currentConfig = await homebridge.getPluginConfig();
135
+
136
+ // Retrieve the set of feature options available to us.
137
+ const features = (await homebridge.request("/getOptions")) ?? [];
138
+
139
+ // Initialize our feature option configuration.
140
+ this.#featureOptions = new FeatureOptions(features.categories, features.options, this.currentConfig[0].options ?? []);
141
+
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.
143
+
144
+ // Retrieve the table for the our list of controllers and global options.
145
+ const controllersTable = document.getElementById("controllersTable");
146
+
147
+ // Start with a clean slate.
148
+ controllersTable.innerHTML = "";
149
+ this.devicesTable.innerHTML = "";
150
+ this.#configTable.innerHTML = "";
151
+ this.webUiDeviceList = [];
152
+
153
+ // Create our hover style for our sidebar.
154
+ const sidebarHoverStyle = document.createElement("style");
155
+
156
+ // We emulate the styles that Bootstrap uses when hovering over a table, accounting for both light and dark modes.
157
+ sidebarHoverStyle.innerHTML = "@media (prefers-color-scheme: dark) { .hbup-hover td:hover { background-color: #212121; color: #FFA000 } }" +
158
+ "@media (prefers-color-scheme: light) { .hbup-hover td:hover { background-color: #ECECEC; } }";
159
+
160
+ document.head.appendChild(sidebarHoverStyle);
161
+
162
+ // Add our hover styles to the controllers and devices tables.
163
+ controllersTable.classList.add("hbup-hover");
164
+ this.devicesTable.classList.add("hbup-hover");
165
+
166
+ // Hide the UI until we're ready.
167
+ document.getElementById("sidebar").style.display = "none";
168
+ document.getElementById("headerInfo").style.display = "none";
169
+ document.getElementById("deviceStatsTable").style.display = "none";
170
+
171
+ // If we haven't configured any controllers, we're done.
172
+ if(this.#hasControllers && !this.currentConfig[0]?.controllers?.length) {
173
+
174
+ document.getElementById("headerInfo").innerHTML = "Please configure a controller to access in the main settings tab before configuring feature options.";
175
+ document.getElementById("headerInfo").style.display = "";
176
+ homebridge.hideSpinner();
177
+
178
+ return;
179
+ }
180
+
181
+ // Initialize our informational header.
182
+ document.getElementById("headerInfo").innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:" +
183
+ "<br><i class=\"text-warning\">Global options</i> (lowest priority) &rarr; " +
184
+ (this.#hasControllers ? "<i class=\"text-success\">Controller options</i> &rarr; " : "") +
185
+ "<i class=\"text-info\">Device options</i> (highest priority)";
186
+
187
+ // Enumerate our global options.
188
+ const trGlobal = document.createElement("tr");
189
+
190
+ // Create the cell for our global options.
191
+ const tdGlobal = document.createElement("td");
192
+
193
+ tdGlobal.classList.add("m-0", "p-0", "w-100");
194
+
195
+ // Create our label target.
196
+ const globalLabel = document.createElement("label");
197
+
198
+ globalLabel.name = "Global Options";
199
+ globalLabel.appendChild(document.createTextNode("Global Options"));
200
+ globalLabel.style.cursor = "pointer";
201
+ globalLabel.classList.add("m-0", "p-0", "pl-1", "w-100");
202
+
203
+ globalLabel.addEventListener("click", () => this.#showSidebar(null));
204
+
205
+ // Add the global options label.
206
+ tdGlobal.appendChild(globalLabel);
207
+ tdGlobal.style.fontWeight = "bold";
208
+
209
+ // Add the global cell to the table.
210
+ trGlobal.appendChild(tdGlobal);
211
+
212
+ // Now add it to the overall controllers table.
213
+ controllersTable.appendChild(trGlobal);
214
+
215
+ // Add it as another controller of device, for UI purposes.
216
+ (this.#hasControllers ? this.webUiControllerList : this.webUiDeviceList).push(globalLabel);
217
+
218
+ if(this.#hasControllers) {
219
+
220
+ // Create a row for our controllers.
221
+ const trController = document.createElement("tr");
222
+
223
+ // Disable any pointer events and hover activity.
224
+ trController.style.pointerEvents = "none";
225
+
226
+ // Create the cell for our controller category row.
227
+ const tdController = document.createElement("td");
228
+
229
+ tdController.classList.add("m-0", "p-0", "pl-1", "w-100");
230
+
231
+ // Add the category name, with appropriate casing.
232
+ tdController.appendChild(document.createTextNode(this.#sidebar.controllerLabel));
233
+ tdController.style.fontWeight = "bold";
234
+
235
+ // Add the cell to the table row.
236
+ trController.appendChild(tdController);
237
+
238
+ // Add the table row to the table.
239
+ controllersTable.appendChild(trController);
240
+
241
+ for(const controller of this.currentConfig[0].controllers) {
242
+
243
+ // Create a row for this controller.
244
+ const trDevice = document.createElement("tr");
245
+
246
+ trDevice.classList.add("m-0", "p-0");
247
+
248
+ // Create a cell for our controller.
249
+ const tdDevice = document.createElement("td");
250
+
251
+ tdDevice.classList.add("m-0", "p-0", "w-100");
252
+
253
+ const label = document.createElement("label");
254
+
255
+ label.name = controller.address;
256
+ label.appendChild(document.createTextNode(controller.address));
257
+ label.style.cursor = "pointer";
258
+ label.classList.add("mx-2", "my-0", "p-0", "w-100");
259
+
260
+ label.addEventListener("click", () => this.#showSidebar(controller));
261
+
262
+ // Add the controller label to our cell.
263
+ tdDevice.appendChild(label);
264
+
265
+ // Add the cell to the table row.
266
+ trDevice.appendChild(tdDevice);
267
+
268
+ // Add the table row to the table.
269
+ controllersTable.appendChild(trDevice);
270
+
271
+ this.webUiControllerList.push(label);
272
+ }
273
+ }
274
+
275
+ // All done. Let the user interact with us.
276
+ homebridge.hideSpinner();
277
+
278
+ // Default the user on our global settings if we have no controller.
279
+ this.#showSidebar(this.#hasControllers ? this.currentConfig[0].controllers[0] : null);
280
+ }
281
+
282
+ // Show the device list taking the controller context into account.
283
+ async #showSidebar(controller) {
284
+
285
+ // Show the beachball while we setup.
286
+ homebridge.showSpinner();
287
+
288
+ // Grab the list of devices we're displaying.
289
+ this.#devices = await this.#getDevices(controller);
290
+
291
+ if(this.#hasControllers) {
292
+
293
+ // Make sure we highlight the selected controller so the user knows where we are.
294
+ this.webUiControllerList.map(webUiEntry => (webUiEntry.name === (controller ? controller.address : "Global Options")) ?
295
+ webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white"));
296
+
297
+ // Unable to connect to the controller for some reason.
298
+ if(controller && !this.#devices?.length) {
299
+
300
+ this.devicesTable.innerHTML = "";
301
+ this.#configTable.innerHTML = "";
302
+
303
+ document.getElementById("headerInfo").innerHTML = ["Unable to connect to the controller.",
304
+ "Check the Settings tab to verify the controller details are correct.",
305
+ "<code class=\"text-danger\">" + (await homebridge.request("/getErrorMessage")) + "</code>"].join("<br>");
306
+ document.getElementById("headerInfo").style.display = "";
307
+ this.deviceStatsTable.style.display = "none";
308
+
309
+ homebridge.hideSpinner();
310
+
311
+ return;
312
+ }
313
+
314
+ // The first entry returned by getDevices() must always be the controller.
315
+ this.#controller = this.#devices[0]?.serial ?? null;
316
+ }
317
+
318
+ // Make the UI visible.
319
+ document.getElementById("headerInfo").style.display = "";
320
+ document.getElementById("sidebar").style.display = "";
321
+
322
+ // Wipe out the device list, except for our global entry.
323
+ this.webUiDeviceList.splice(1, this.webUiDeviceList.length);
324
+
325
+ // Start with a clean slate.
326
+ this.devicesTable.innerHTML = "";
327
+
328
+ // Populate our devices sidebar.
329
+ this.#sidebar.showDevices(controller, this.#devices);
330
+
331
+ // Display the feature options to the user.
332
+ this.showDeviceOptions(controller ? this.#devices[0].serial : "Global Options");
333
+
334
+ // All done. Let the user interact with us.
335
+ homebridge.hideSpinner();
336
+ }
337
+
338
+ // Show feature option information for a specific device, controller, or globally.
339
+ showDeviceOptions(deviceId) {
340
+
341
+ homebridge.showSpinner();
342
+
343
+ // Update the selected device for visibility.
344
+ this.webUiDeviceList.map(webUiEntry => (webUiEntry.name === deviceId) ?
345
+ webUiEntry.parentElement.classList.add("bg-info", "text-white") : webUiEntry.parentElement.classList.remove("bg-info", "text-white"));
346
+
347
+ // Populate the device information info pane.
348
+ const currentDevice = this.#devices.find(device => device.serial === deviceId);
349
+
350
+ // Populate the details view. If there's no device specified, the context is considered global and we hide the device details view.
351
+ if(!currentDevice) {
352
+
353
+ this.deviceStatsTable.style.display = "none";
354
+ }
355
+
356
+ this.#infoPanel(currentDevice);
357
+
358
+ if(currentDevice) {
359
+
360
+ this.deviceStatsTable.style.display = "";
361
+ }
362
+
363
+ // Start with a clean slate.
364
+ this.#configTable.innerHTML = "";
365
+
366
+ for(const category of this.#featureOptions.categories) {
367
+
368
+ // 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.
369
+ if(!this.#ui.validOptionCategory(currentDevice, category)) {
370
+
371
+ continue;
372
+ }
373
+
374
+ const optionTable = document.createElement("table");
375
+ const thead = document.createElement("thead");
376
+ const tbody = document.createElement("tbody");
377
+ const trFirst = document.createElement("tr");
378
+ const th = document.createElement("th");
379
+
380
+ // Set our table options.
381
+ optionTable.classList.add("table", "table-borderless", "table-sm", "table-hover");
382
+ th.classList.add("p-0");
383
+ th.style.fontWeight = "bold";
384
+ th.colSpan = 3;
385
+ tbody.classList.add("table-bordered");
386
+
387
+ // Add the feature option category description.
388
+ th.appendChild(document.createTextNode(category.description + (!currentDevice ? " (Global)" :
389
+ (this.#ui.isController(currentDevice) ? " (Controller-specific)" : " (Device-specific)"))));
390
+
391
+ // Add the table header to the row.
392
+ trFirst.appendChild(th);
393
+
394
+ // Add the table row to the table head.
395
+ thead.appendChild(trFirst);
396
+
397
+ // Finally, add the table head to the table.
398
+ optionTable.appendChild(thead);
399
+
400
+ // Keep track of the number of options we have made available in a given category.
401
+ let optionsVisibleCount = 0;
402
+
403
+ // Now enumerate all the feature options for a given device.
404
+ for(const option of this.#featureOptions.options[category.name]) {
405
+
406
+ // Only show feature options that are valid for this device.
407
+ if(!this.#ui.validOption(currentDevice, option)) {
408
+
409
+ continue;
410
+ }
411
+
412
+ // Expand the full feature option.
413
+ const featureOption = this.#featureOptions.expandOption(category, option);
414
+
415
+ // Create the next table row.
416
+ const trX = document.createElement("tr");
417
+
418
+ trX.classList.add("align-top");
419
+ trX.id = "row-" + featureOption;
420
+
421
+ // Create a checkbox for the option.
422
+ const tdCheckbox = document.createElement("td");
423
+
424
+ // Create the actual checkbox for the option.
425
+ const checkbox = document.createElement("input");
426
+
427
+ checkbox.type = "checkbox";
428
+ checkbox.readOnly = false;
429
+ checkbox.id = featureOption;
430
+ checkbox.name = featureOption;
431
+ checkbox.value = featureOption + (!currentDevice ? "" : ("." + currentDevice.serial));
432
+
433
+ let initialValue = undefined;
434
+ let initialScope;
435
+
436
+ // Determine our initial option scope to show the user what's been set.
437
+ switch(initialScope = this.#featureOptions.scope(featureOption, currentDevice?.serial, this.#controller)) {
438
+
439
+ case "global":
440
+ case "controller":
441
+
442
+ // 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.
443
+ if(!currentDevice) {
444
+
445
+ if(this.#featureOptions.isValue(featureOption)) {
446
+
447
+ checkbox.checked = this.#featureOptions.exists(featureOption);
448
+ initialValue = this.#featureOptions.value(checkbox.id);
449
+ } else {
450
+
451
+ checkbox.checked = this.#featureOptions.test(featureOption);
452
+ }
453
+
454
+ if(checkbox.checked) {
455
+
456
+ checkbox.indeterminate = false;
457
+ }
458
+
459
+ } else {
460
+
461
+ if(this.#featureOptions.isValue(featureOption)) {
462
+
463
+ initialValue = this.#featureOptions.value(checkbox.id, (initialScope === "controller") ? this.#controller : undefined);
464
+ }
465
+
466
+ checkbox.readOnly = checkbox.indeterminate = true;
467
+ }
468
+
469
+ break;
470
+
471
+ case "device":
472
+ case "none":
473
+ default:
474
+
475
+ if(this.#featureOptions.isValue(featureOption)) {
476
+
477
+ checkbox.checked = this.#featureOptions.exists(featureOption, currentDevice?.serial);
478
+ initialValue = this.#featureOptions.value(checkbox.id, currentDevice?.serial);
479
+ } else {
480
+
481
+ checkbox.checked = this.#featureOptions.test(featureOption, currentDevice?.serial);
482
+ }
483
+
484
+ break;
485
+ }
486
+
487
+ checkbox.defaultChecked = option.default;
488
+ checkbox.classList.add("mx-2");
489
+
490
+ // Add the checkbox to the table cell.
491
+ tdCheckbox.appendChild(checkbox);
492
+
493
+ // Add the checkbox to the table row.
494
+ trX.appendChild(tdCheckbox);
495
+
496
+ const tdLabel = document.createElement("td");
497
+
498
+ tdLabel.classList.add("w-100");
499
+ tdLabel.colSpan = 2;
500
+
501
+ let inputValue = null;
502
+
503
+ // Add an input field if we have a value-centric feature option.
504
+ if(this.#featureOptions.isValue(featureOption)) {
505
+
506
+ const tdInput = document.createElement("td");
507
+
508
+ tdInput.classList.add("mr-2");
509
+ tdInput.style.width = "10%";
510
+
511
+ inputValue = document.createElement("input");
512
+ inputValue.type = "text";
513
+ inputValue.value = initialValue ?? option.defaultValue;
514
+ inputValue.size = 5;
515
+ inputValue.readOnly = !checkbox.checked;
516
+
517
+ // Add or remove the setting from our configuration when we've changed our state.
518
+ inputValue.addEventListener("change", async () => {
519
+
520
+ // Find the option in our list and delete it if it exists.
521
+ const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!currentDevice ? "" : ("\\." + currentDevice.serial)) + "\\.[^\\.]+$", "gi");
522
+ const newOptions = this.#featureOptions.configuredOptions.filter(entry => !optionRegex.test(entry));
523
+
524
+ if(checkbox.checked) {
525
+
526
+ newOptions.push("Enable." + checkbox.value + "." + inputValue.value);
527
+ } else if(checkbox.indeterminate) {
528
+
529
+ // If we're in an indeterminate state, we need to traverse the tree to get the upstream value we're inheriting.
530
+ inputValue.value = (currentDevice?.serial !== this.#controller) ?
531
+ (this.#featureOptions.value(checkbox.id, this.#controller) ?? this.#featureOptions.value(checkbox.id)) :
532
+ (this.#featureOptions.value(checkbox.id) ?? option.defaultValue);
533
+ } else {
534
+
535
+ inputValue.value = option.defaultValue;
536
+ }
537
+
538
+ // Update our configuration in Homebridge.
539
+ this.currentConfig[0].options = newOptions;
540
+ this.#featureOptions.configuredOptions = newOptions;
541
+ await homebridge.updatePluginConfig(this.currentConfig);
542
+ });
543
+
544
+ tdInput.appendChild(inputValue);
545
+ trX.appendChild(tdInput);
546
+ }
547
+
548
+ // Create a label for the checkbox with our option description.
549
+ const labelDescription = document.createElement("label");
550
+
551
+ labelDescription.for = checkbox.id;
552
+ labelDescription.style.cursor = "pointer";
553
+ labelDescription.classList.add("user-select-none", "my-0", "py-0");
554
+
555
+ // Highlight options for the user that are different than our defaults.
556
+ const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serial, this.#controller);
557
+
558
+ if(scopeColor) {
559
+
560
+ labelDescription.classList.add(scopeColor);
561
+ }
562
+
563
+ // Add or remove the setting from our configuration when we've changed our state.
564
+ checkbox.addEventListener("change", async () => {
565
+
566
+ // Find the option in our list and delete it if it exists.
567
+ const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!currentDevice ? "" : ("\\." + currentDevice.serial)) + "$", "gi");
568
+ const newOptions = this.#featureOptions.configuredOptions.filter(entry => !optionRegex.test(entry));
569
+
570
+ // Figure out if we've got the option set upstream.
571
+ let upstreamOption = false;
572
+
573
+ // 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.
574
+ switch(this.#featureOptions.scope(checkbox.id, (currentDevice && (currentDevice.serial !== this.#controller)) ? this.#controller : undefined)) {
575
+
576
+ case "device":
577
+ case "controller":
578
+
579
+ if(currentDevice.serial !== this.#controller) {
580
+
581
+ upstreamOption = true;
582
+ }
583
+
584
+ break;
585
+
586
+ case "global":
587
+
588
+ if(currentDevice) {
589
+
590
+ upstreamOption = true;
591
+ }
592
+
593
+ break;
594
+
595
+ default:
596
+
597
+ break;
598
+ }
599
+
600
+ // For value-centric feature options, if there's an upstream value assigned above us, we don't allow for an unchecked state as it doesn't make sense in this
601
+ // context.
602
+ if(checkbox.readOnly && (!this.#featureOptions.isValue(featureOption) || (this.#featureOptions.isValue(featureOption) && inputValue && !upstreamOption))) {
603
+
604
+ // We're truly unchecked. We need this because a checkbox can be in both an unchecked and indeterminate simultaneously,
605
+ // so we use the readOnly property to let us know that we've just cycled from an indeterminate state.
606
+ checkbox.checked = checkbox.readOnly = false;
607
+ } else if(!checkbox.checked) {
608
+
609
+ // 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.
610
+ if(upstreamOption) {
611
+
612
+ // 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
613
+ // indeterminate state. This allows us to effectively cycle between three states.
614
+ checkbox.readOnly = checkbox.indeterminate = true;
615
+ }
616
+
617
+ if(this.#featureOptions.isValue(featureOption) && inputValue) {
618
+
619
+ inputValue.readOnly = true;
620
+ }
621
+ } else if(checkbox.checked) {
622
+
623
+ // We've explicitly checked this option.
624
+ checkbox.readOnly = checkbox.indeterminate = false;
625
+
626
+ if(this.#featureOptions.isValue(featureOption) && inputValue) {
627
+
628
+ inputValue.readOnly = false;
629
+ }
630
+ }
631
+
632
+ // The setting is different from the default, highlight it for the user, accounting for upstream scope, and add it to our configuration.
633
+ if(!checkbox.indeterminate && ((checkbox.checked !== option.default) || upstreamOption)) {
634
+
635
+ labelDescription.classList.add("text-info");
636
+ newOptions.push((checkbox.checked ? "Enable." : "Disable.") + checkbox.value);
637
+ } else {
638
+
639
+ // We've reset to the defaults, remove our highlighting.
640
+ labelDescription.classList.remove("text-info");
641
+ }
642
+
643
+ // Update our Homebridge configuration.
644
+ if(this.#featureOptions.isValue(featureOption) && inputValue) {
645
+
646
+ // Inform our value-centric feature option to update Homebridge.
647
+ const changeEvent = new Event("change");
648
+
649
+ inputValue.dispatchEvent(changeEvent);
650
+ } else {
651
+
652
+ // Update our configuration in Homebridge.
653
+ this.currentConfig[0].options = newOptions;
654
+ this.#featureOptions.configuredOptions = newOptions;
655
+ await homebridge.updatePluginConfig(this.currentConfig);
656
+ }
657
+
658
+ // If we've reset to defaults, make sure our color coding for scope is reflected.
659
+ if((checkbox.checked === option.default) || checkbox.indeterminate) {
660
+
661
+ const scopeColor = this.#featureOptions.color(featureOption, currentDevice?.serial, this.#controller);
662
+
663
+ if(scopeColor) {
664
+
665
+ labelDescription.classList.add(scopeColor);
666
+ }
667
+ }
668
+
669
+ // Adjust visibility of other feature options that depend on us.
670
+ if(this.#featureOptions.groups[checkbox.id]) {
671
+
672
+ const entryVisibility = this.#featureOptions.test(featureOption, currentDevice?.serial) ? "" : "none";
673
+
674
+ // Lookup each feature option setting and set the visibility accordingly.
675
+ for(const entry of this.#featureOptions.groups[checkbox.id]) {
676
+
677
+ document.getElementById("row-" + entry).style.display = entryVisibility;
678
+ }
679
+ }
680
+ });
681
+
682
+ // Add the actual description for the option after the checkbox.
683
+ labelDescription.appendChild(document.createTextNode(option.description));
684
+
685
+ // Add the label to the table cell.
686
+ tdLabel.appendChild(labelDescription);
687
+
688
+ // Provide a cell-wide target to click on options.
689
+ tdLabel.addEventListener("click", () => checkbox.click());
690
+
691
+ // Add the label table cell to the table row.
692
+ trX.appendChild(tdLabel);
693
+
694
+ // Adjust the visibility of the feature option, if it's logically grouped.
695
+ if((option.group !== undefined) && !this.#featureOptions.test(category.name + (option.group.length ? ("." + option.group) : ""), currentDevice?.serial)) {
696
+
697
+ trX.style.display = "none";
698
+ } else {
699
+
700
+ // Increment the visible option count.
701
+ optionsVisibleCount++;
702
+ }
703
+
704
+ // Add the table row to the table body.
705
+ tbody.appendChild(trX);
706
+ }
707
+
708
+ // Add the table body to the table.
709
+ optionTable.appendChild(tbody);
710
+
711
+ // If we have no options visible in a given category, then hide the entire category.
712
+ if(!optionsVisibleCount) {
713
+
714
+ optionTable.style.display = "none";
715
+ }
716
+
717
+ // Add the table to the page.
718
+ this.#configTable.appendChild(optionTable);
719
+ }
720
+
721
+ homebridge.hideSpinner();
722
+ }
723
+
724
+ // Our default device information panel handler.
725
+ #showDeviceInfoPanel(device) {
726
+
727
+ const deviceFirmware = document.getElementById("device_firmware") ?? {};
728
+ const deviceSerial = document.getElementById("device_serial") ?? {};
729
+
730
+ // No device specified, we must be in a global context.
731
+ if(!device) {
732
+
733
+ deviceFirmware.innerHTML = "N/A";
734
+ deviceSerial.innerHTML = "N/A";
735
+
736
+ return;
737
+ }
738
+
739
+ // Display our device details.
740
+ deviceFirmware.innerHTML = device.firmwareVersion;
741
+ deviceSerial.innerHTML = device.serial;
742
+ }
743
+
744
+ // Default method for enumerating the device list in the sidebar.
745
+ #showSidebarDevices() {
746
+
747
+ // Show the devices list only if we have actual devices to show.
748
+ if(!this.#devices?.length) {
749
+
750
+ return;
751
+ }
752
+
753
+ // Create a row for this device category.
754
+ const trCategory = document.createElement("tr");
755
+
756
+ // Disable any pointer events and hover activity.
757
+ trCategory.style.pointerEvents = "none";
758
+
759
+ // Create the cell for our device category row.
760
+ const tdCategory = document.createElement("td");
761
+
762
+ tdCategory.classList.add("m-0", "p-0", "pl-1", "w-100");
763
+
764
+ // Add the category name, with appropriate casing.
765
+ tdCategory.appendChild(document.createTextNode(this.#sidebar.deviceLabel));
766
+ tdCategory.style.fontWeight = "bold";
767
+
768
+ // Add the cell to the table row.
769
+ trCategory.appendChild(tdCategory);
770
+
771
+ // Add the table row to the table.
772
+ this.devicesTable.appendChild(trCategory);
773
+
774
+ for(const device of this.#devices) {
775
+
776
+ // Create a row for this device.
777
+ const trDevice = document.createElement("tr");
778
+
779
+ trDevice.classList.add("m-0", "p-0");
780
+
781
+ // Create a cell for our device.
782
+ const tdDevice = document.createElement("td");
783
+
784
+ tdDevice.classList.add("m-0", "p-0", "w-100");
785
+
786
+ const label = document.createElement("label");
787
+
788
+ label.name = device.serial;
789
+ label.appendChild(document.createTextNode(device.name ?? "Unknown"));
790
+ label.style.cursor = "pointer";
791
+ label.classList.add("mx-2", "my-0", "p-0", "w-100");
792
+
793
+ label.addEventListener("click", () => this.showDeviceOptions(device.serial));
794
+
795
+ // Add the device label to our cell.
796
+ tdDevice.appendChild(label);
797
+
798
+ // Add the cell to the table row.
799
+ trDevice.appendChild(tdDevice);
800
+
801
+ // Add the table row to the table.
802
+ this.devicesTable.appendChild(trDevice);
803
+
804
+ this.webUiDeviceList.push(label);
805
+ }
806
+ }
807
+
808
+ // Default method for retrieving the device list from the Homebridge accessory cache.
809
+ async #getHomebridgeDevices() {
810
+
811
+ // Retrieve the full list of cached accessories.
812
+ let devices = await homebridge.getCachedAccessories();
813
+
814
+ // Filter out only the components we're interested in.
815
+ devices = devices.map(device => ({
816
+
817
+ firmwareVersion: (device.services.find(service => service.constructorName ===
818
+ "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "FirmwareRevision")?.value ?? ""),
819
+ name: device.displayName,
820
+ serial: (device.services.find(service => service.constructorName ===
821
+ "AccessoryInformation")?.characteristics.find(characteristic => characteristic.constructorName === "SerialNumber")?.value ?? "")
822
+ }));
823
+
824
+ // Sort it for posterity.
825
+ devices.sort((a, b) => {
826
+
827
+ const aCase = (a.name ?? "").toLowerCase();
828
+ const bCase = (b.name ?? "").toLowerCase();
829
+
830
+ return aCase > bCase ? 1 : (bCase > aCase ? -1 : 0);
831
+ });
832
+
833
+ // Return the list.
834
+ return devices;
835
+ }
836
+ }