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