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