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