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
@@ -0,0 +1,677 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * webUi-featureOptions.mjs: Lifecycle coordinator for the feature options webUI.
4
+ */
5
+ "use strict";
6
+
7
+ import { initialState, reducer } from "./webUi-featureOptions/state.mjs";
8
+ import { FeatureOptionsStore } from "./webUi-featureOptions/store.mjs";
9
+ import { buildCatalogIndex } from "./featureOptions.js";
10
+ import { delay } from "./webUi-featureOptions/utils.mjs";
11
+ import { mountConnectionErrorView } from "./webUi-featureOptions/views/connectionError.mjs";
12
+ import { mountDeviceInfoView } from "./webUi-featureOptions/views/deviceInfo.mjs";
13
+ import { mountHeaderView } from "./webUi-featureOptions/views/header.mjs";
14
+ import { mountNavView } from "./webUi-featureOptions/views/nav.mjs";
15
+ import { mountOptionsView } from "./webUi-featureOptions/views/options.mjs";
16
+ import { mountSearchView } from "./webUi-featureOptions/views/search.mjs";
17
+ import { registerKeyboardEffect } from "./webUi-featureOptions/effects/keyboard.mjs";
18
+ import { registerPersistEffect } from "./webUi-featureOptions/effects/persist.mjs";
19
+ import { registerThemeEffect } from "./webUi-featureOptions/effects/theme.mjs";
20
+ import { registerTokensEffect } from "./webUi-featureOptions/effects/tokens.mjs";
21
+
22
+ /**
23
+ * Upper bound on how long hide() will block waiting for the navigate-away flush to complete. This is a teardown safety cap, NOT a perf knob: the normal flush
24
+ * completes in well under it, and the only thing it guards against is a host `updatePluginConfig` that never settles. `updatePluginConfig` accepts no `AbortSignal`
25
+ * and is documented un-abortable, so without this cap a stalled write would leave flush() (and thus hide(), and the spinner-wrapped tab switch that awaits it) pending
26
+ * indefinitely. On timeout the in-flight commit continues independently and still lands if the host recovers; we simply stop blocking the UI on it.
27
+ */
28
+ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
29
+
30
+ /**
31
+ * @typedef {Object} Device
32
+ * @property {string} firmwareRevision - The firmware version of the device.
33
+ * @property {string} manufacturer - The manufacturer of the device.
34
+ * @property {string} model - The model identifier of the device.
35
+ * @property {string} name - The display name of the device.
36
+ * @property {string} serialNumber - The unique serial number of the device.
37
+ * @property {string} [sidebarGroup] - Optional grouping identifier for sidebar organization.
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} Controller
42
+ * @property {string} address - The network address of the controller.
43
+ * @property {string} name - The display name of the controller.
44
+ * @property {string} serialNumber - The unique serial number of the controller.
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} FeatureOptionsConfig
49
+ * @property {Function} [getControllers] - Handler to retrieve available controllers.
50
+ * @property {Function} [getDevices] - Handler to retrieve devices for a controller.
51
+ * @property {Function} [infoPanel] - Handler to display device information.
52
+ * @property {Object} [sidebar] - Sidebar configuration options.
53
+ * @property {string} [sidebar.controllerLabel="Controllers"] - Label for the controllers section.
54
+ * @property {string} [sidebar.deviceLabel="Devices"] - Label for the devices section.
55
+ * @property {Object} [ui] - UI validation and display options.
56
+ * @property {number} [ui.controllerRetryEnableDelayMs=5000] - Interval before enabling a retry button when connecting to a controller.
57
+ * @property {Function} [ui.isController] - Validates if a device is a controller.
58
+ * @property {Function} [ui.validOption] - Validates if an option should display for a device.
59
+ * @property {Function} [ui.validOptionCategory] - Validates if a category should display for a device.
60
+ */
61
+
62
+ /**
63
+ * webUiFeatureOptions - Lifecycle coordinator for the feature options webUI.
64
+ *
65
+ * Boots the reactive state container, registers every effect (persist, theme, tokens, keyboard) and mounts every view (header, device info, nav, search, options,
66
+ * connection error) once the page becomes active. Tears down the entire system in one operation on cleanup by aborting the page-level signal: every effect's
67
+ * subscription and every view's listener was registered with `{signal}`, so abort cascades through them automatically.
68
+ *
69
+ * Public API: constructor takes the same options shape, `show()` reveals the UI, `hide()` is the navigate-away (it flushes any pending edit, then tears down),
70
+ * `cleanup()` is immediate destructive teardown (may drop an unsaved debounced edit; for forced/synchronous disposal), `getHomebridgeDevices()` is the default
71
+ * device source. Plugins consuming the library see no change to this surface.
72
+ *
73
+ * Internally, the store owns per-show state, effects own side effects, views own DOM, and the orchestrator is the lifecycle seam that boots and tears them down. The one
74
+ * piece of state it keeps itself is #initialOptions - the revert-to-saved snapshot - which must outlive the store's per-show() reset; all else flows through the store.
75
+ *
76
+ * @example
77
+ *
78
+ * // The orchestrator opens the config session and hands it to show(); the plugin hooks receive their config injected (never reaching for it).
79
+ * const session = await PluginConfigSession.open({ host: homebridge, name: "My Plugin" });
80
+ * const featureOptionsUI = new webUiFeatureOptions({
81
+ * getControllers: ({ config }) => myPlugin.controllersFrom(config),
82
+ * getDevices: async (controller, { config }) => controller ? myPlugin.getDevices(controller, config) : [],
83
+ * ui: {
84
+ * isController: (device) => device?.type === "controller",
85
+ * validOption: (device, option) => device?.type !== "controller" || !option.name.startsWith("Video.")
86
+ * }
87
+ * });
88
+ *
89
+ * await featureOptionsUI.show(session);
90
+ *
91
+ * // Later, when navigating away (persists any pending edit, then tears down):
92
+ * await featureOptionsUI.hide();
93
+ */
94
+ export class webUiFeatureOptions {
95
+
96
+ // Plugin-provided configuration captured at construction. Threaded through to effects and views at mount time via closures; never mutated after the constructor
97
+ // returns.
98
+ #config;
99
+
100
+ // The persist effect's flush handle, captured when the effect is registered in show(). hide() awaits it (bounded) to drain any debounced-but-unwritten edit to disk
101
+ // before tearing the page down; the visibilitychange handler fires it best-effort on browser background/close. Recreated on every show(); nulled out on cleanup().
102
+ #flushPersist;
103
+
104
+ // The page-level abort controller. Aborting it tears down every effect and every view in one operation. Recreated on every show(); nulled out on cleanup().
105
+ #pageAbort;
106
+
107
+ // The plugin-config session, supplied by the orchestrator on show(). The single owner of the persisted config: the page reads its base config and persists option
108
+ // edits through it. Held (not nulled on cleanup) so the editedConfig getter stays queryable after hide() / cleanup(), and re-used across show() cycles.
109
+ #session;
110
+
111
+ // The reactive state container. Created in show() with empty placeholder state; the loaded catalog and configured options arrive via the model:loaded dispatch, and
112
+ // a fresh instance replaces the prior one on every show() call. Held (not nulled on cleanup) so the editedConfig getter stays queryable after hide() / cleanup(). The
113
+ // orchestrator never reaches into store state for state management - all reads/writes go through dispatched actions and subscribed events.
114
+ #store;
115
+
116
+ // The configuredOptions array captured at the FIRST show()'s `model:loaded`. Survives subsequent cleanup() / show() cycles so a re-show that loads a set-equal
117
+ // (possibly reordered) options array preserves the original snapshot for revert-to-saved. The set-equality probe in show() is the seam where this is decided.
118
+ // Set on first model:loaded; updated only when the loaded options are NOT set-equal to the stored snapshot.
119
+ #initialOptions;
120
+
121
+ /**
122
+ * Initialize the feature options webUI with customizable configuration.
123
+ *
124
+ * @param {FeatureOptionsConfig} options - Configuration options for the webUI.
125
+ */
126
+ constructor(options = {}) {
127
+
128
+ const {
129
+
130
+ getControllers = undefined,
131
+ getDevices = this.getHomebridgeDevices,
132
+ infoPanel = undefined,
133
+ sidebar = {},
134
+ ui = {}
135
+ } = options;
136
+
137
+ this.#config = {
138
+
139
+ controllerRetryEnableDelayMs: ui.controllerRetryEnableDelayMs ?? 5000,
140
+ getControllers,
141
+ getDevices,
142
+ infoPanel,
143
+ labelControllers: sidebar.controllerLabel ?? "Controllers",
144
+ labelDevices: sidebar.deviceLabel ?? "Devices",
145
+ validators: {
146
+
147
+ isController: ui.isController ?? (() => false),
148
+ validOption: ui.validOption ?? (() => true),
149
+ validOptionCategory: ui.validOptionCategory ?? (() => true)
150
+ }
151
+ };
152
+
153
+ this.#flushPersist = null;
154
+ this.#pageAbort = null;
155
+ this.#session = null;
156
+ this.#store = null;
157
+ this.#initialOptions = null;
158
+ }
159
+
160
+ /**
161
+ * The editing buffer: the persisted configuration with the primary platform entry's options replaced by the live, unsaved edits. This is deliberately NOT "the
162
+ * config" - the persisted config is owned by the session; this view overlays the in-flight `configuredOptions` for any consumer that wants to see config-as-edited.
163
+ * Returns an empty array before show() has supplied the session. Built fresh on each read - external consumers should not rely on reference equality across calls.
164
+ *
165
+ * Preserved as a getter (not a public field) so the implementation is free to change without breaking callers; the observable shape is what plugins might consume.
166
+ *
167
+ * @returns {readonly Object[]} The edited plugin-config array.
168
+ */
169
+ get editedConfig() {
170
+
171
+ if(!this.#session) {
172
+
173
+ return [];
174
+ }
175
+
176
+ // Before the store boots (or after a re-show that has not yet dispatched model:loaded) there are no live edits, so the buffer is just the persisted config with
177
+ // the primary entry's own saved options. Once the store is live, the in-flight configuredOptions override them.
178
+ const options = this.#store ? this.#store.state.configuredOptions : (this.#session.platform.options ?? []);
179
+
180
+ return [ { ...this.#session.platform, options }, ...this.#session.entries.slice(1) ];
181
+ }
182
+
183
+ /**
184
+ * Render the feature options webUI. The main entry point.
185
+ *
186
+ * Boot sequence:
187
+ *
188
+ * 1. Synchronous page-shell setup: hide schema form, update menu state, reveal the feature-options page. The user sees the layout immediately; the async I/O
189
+ * below populates each region against the visible shell.
190
+ * 2. Tear down any prior show() cycle via hide() (it flushes any pending edit before tearing down), then re-sync the session against the host config; a sync
191
+ * failure toasts the error message and bails.
192
+ * 3. Create the page abort controller. Every effect and view registers listeners with this signal so cleanup() tears them all down in one operation.
193
+ * 4. Fire the plugin I/O requests in parallel: controllers (if configured) and the feature catalog. The plugin config is already held by the session, so there
194
+ * is no config fetch to overlap here - the base options come from the session's primary entry.
195
+ * 5. Adopt the design tokens. Synchronous - tokens are static declarations with no I/O dependencies.
196
+ * 6. Fire the theme effect, persist effect, keyboard effect in parallel. The theme effect's I/O (Bootstrap probe) runs in the background.
197
+ * 7. Once controllers resolves: if controller-based mode with empty controllers, show the no-controllers message and return.
198
+ * 8. Pre-fire the devices fetch for the initial controller so it overlaps with the feature catalog.
199
+ * 9. Once the feature catalog resolves: build the catalog, dispatch model:loaded, mount all views.
200
+ * 10. Once devices resolve: dispatch devices:loaded. If a controller is selected but no devices were returned, fetch the error message, dispatch
201
+ * connection:error, and return; otherwise set the initial scope.
202
+ * 11. Reveal regions that views render into.
203
+ *
204
+ * @param {import("./pluginConfigSession.mjs").PluginConfigSession} session - The config session supplied by the orchestrator; the page's single source of
205
+ * persisted config and the seam through which option edits are persisted.
206
+ * @returns {Promise<void>}
207
+ * @public
208
+ */
209
+ async show(session) {
210
+
211
+ this.#session = session;
212
+
213
+ homebridge.hideSchemaForm();
214
+ updateMenuState();
215
+
216
+ document.getElementById("pageSupport").style.display = "none";
217
+ document.getElementById("pageFeatureOptions").style.display = "block";
218
+
219
+ // Tear down any prior show() cycle first. hide() is now async (it flushes any pending edit from the prior cycle before aborting), so we await it: a re-show via
220
+ // the menu or the connection-error retry must drain the previous cycle's debounced edit before this cycle's store replaces it.
221
+ await this.hide();
222
+
223
+ // Re-read the host config into the session before the page renders against it. show() is the single entry chokepoint (launch, first-run, the menu, and the
224
+ // connection-error retry), so re-syncing here makes "every show is fresh" an unconditional invariant: an edit made in the Settings tab while this page was hidden
225
+ // is reflected on return rather than rendering against a frozen snapshot. The sync lands before getControllers reads session.platform and before the options read
226
+ // below, so both derive from the re-read config. A read failure surfaces as a toast and bails rather than rejecting into a dropped-promise menu handler.
227
+ try {
228
+
229
+ await this.#session.sync();
230
+ } catch(err) {
231
+
232
+ homebridge.toast.error(err?.message ?? String(err), "Error");
233
+
234
+ return;
235
+ }
236
+
237
+ // Fresh page-level abort controller for this show() cycle.
238
+ this.#pageAbort = new AbortController();
239
+
240
+ const signal = this.#pageAbort.signal;
241
+
242
+ // Clear stale DOM from any prior cycle before the views start populating regions.
243
+ clearContainers();
244
+
245
+ // Initialize the store with empty state. The reducer transitions through loading -> ready once model:loaded dispatches.
246
+ this.#store = new FeatureOptionsStore({ initialState: initialState(), reducer });
247
+
248
+ // Fire every independent I/O in parallel. The independent sources: controllers (optional), the /getOptions catalog, and the Homebridge lighting mode (via the
249
+ // theme effect's host). The plugin config is not fetched here - the session already holds it - so getControllers receives the injected platform config rather
250
+ // than reaching for it. None depend on each other; firing them concurrently means total wall-clock time is bounded by the slowest.
251
+ const featuresPromise = homebridge.request("/getOptions").then((response) => response ?? []);
252
+ const controllersPromise = this.#config.getControllers ? this.#config.getControllers({ config: session.platform }) : Promise.resolve(null);
253
+
254
+ // Adopt design tokens. Synchronous; must run before any consumer references `var(--fo-*)`.
255
+ registerTokensEffect({ signal });
256
+
257
+ // Theme effect (async background work for Bootstrap probe; sync stylesheet adoption). Held as a promise so we can await it before the matchMedia listener is
258
+ // registered against any user interaction, but the bulk of init's work overlaps the data fetches below.
259
+ const themeInitPromise = registerThemeEffect({ host: homebridge, signal });
260
+
261
+ // Persist + keyboard effects: register early so they catch any dispatch from the moment model:loaded fires. The persist effect's reference-equality dirty
262
+ // check skips the immediate-run pass since configuredOptions and persistedAnchor share the same empty-array reference in the initial state. We capture the
263
+ // persist effect's flush handle so hide() (and the visibilitychange handler below) can drain a debounced-but-unwritten edit before the page tears down.
264
+ this.#flushPersist = registerPersistEffect({ host: homebridge, session, signal, store: this.#store })?.flush ?? null;
265
+ registerKeyboardEffect({ signal, store: this.#store });
266
+
267
+ // Best-effort browser-exit flush. When the tab is backgrounded or closing while the page is still alive, drain any pending edit so it reaches the host's config
268
+ // model. This is fire-and-forget (the page is hidden/closing with no user present to see it, so an error toast the persist drain may raise on a failed final
269
+ // write lands unseen) and page-signal-keyed so it tears down with the cycle. The instant-hard-close residual (an async write cannot be guaranteed to complete
270
+ // during an immediate unload) is documented and accepted.
271
+ document.addEventListener("visibilitychange", () => {
272
+
273
+ if(document.visibilityState === "hidden") {
274
+
275
+ void this.#flushPersist?.();
276
+ }
277
+ }, { signal });
278
+
279
+ // Wait for controllers (if configured). Empty result in controller-based mode means "no controllers configured" - we show the helper text and bail.
280
+ const controllers = await controllersPromise;
281
+
282
+ if(signal.aborted) {
283
+
284
+ return;
285
+ }
286
+
287
+ if(this.#config.getControllers && (!controllers || (controllers.length === 0))) {
288
+
289
+ showNoControllersMessage();
290
+
291
+ return;
292
+ }
293
+
294
+ const initialController = controllers?.[0] ?? null;
295
+ const devicesPromise = this.#devicesFor(initialController);
296
+
297
+ // Wait for the feature catalog. Build the catalog (catalog index + validators) and dispatch model:loaded so the store transitions to "ready" and views can mount
298
+ // against a populated state. The configured options come from the session's primary entry - the persisted config the orchestrator already loaded.
299
+ const features = await featuresPromise;
300
+
301
+ if(signal.aborted) {
302
+
303
+ return;
304
+ }
305
+
306
+ const loadedOptions = Array.isArray(session.platform?.options) ? session.platform.options : [];
307
+ const catalog = {
308
+
309
+ ...buildCatalogIndex(features.categories ?? [], features.options ?? {}),
310
+
311
+ validators: this.#config.validators
312
+ };
313
+
314
+ // Snapshot for revert-to-saved. Preserved across show() / cleanup() cycles when the re-loaded options are set-equal to the prior snapshot (the user reordered
315
+ // entries but did not save) - this means a revert after re-show restores the original order rather than the reloaded order. First show() sets the snapshot
316
+ // to the just-loaded array; subsequent shows preserve it only when set-equal.
317
+ if(!this.#initialOptions || !sameOptionsSet(this.#initialOptions, loadedOptions)) {
318
+
319
+ this.#initialOptions = [...loadedOptions];
320
+ }
321
+
322
+ this.#store.dispatch({
323
+
324
+ catalog,
325
+ configuredOptions: loadedOptions,
326
+ controllers: controllers ?? [],
327
+ initialOptions: this.#initialOptions,
328
+ mode: this.#config.getControllers ? "controller-based" : "device-only",
329
+ type: "model:loaded"
330
+ });
331
+
332
+ // Mount every view. Each mount is a no-op if its required page element is missing, so the orchestrator does not need to validate the page skeleton up front.
333
+ this.#mountViews(signal);
334
+
335
+ // Wait for the theme's matchMedia listener registration before any user interaction can trigger a theme change. By this point, themeInitPromise has almost
336
+ // always already resolved - it ran in parallel with every other fetch above.
337
+ await themeInitPromise;
338
+
339
+ if(signal.aborted) {
340
+
341
+ return;
342
+ }
343
+
344
+ // Wait for devices. The fetch overlapped with config + features; typically already resolved by now.
345
+ const devices = await devicesPromise;
346
+
347
+ if(signal.aborted) {
348
+
349
+ return;
350
+ }
351
+
352
+ this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices: devices ?? [], type: "devices:loaded" });
353
+
354
+ // Connection-error short-circuit: controller-based mode with a selected controller but no devices returned means the controller is configured but unreachable.
355
+ // Fetch the user-facing error message from the host and dispatch connection:error so the connection-error view takes over the header. Do not transition the
356
+ // scope to a device-view (there are no devices); leave it at global so any subsequent retry re-shows from a known scope.
357
+ if((initialController !== null) && (devices.length === 0)) {
358
+
359
+ const message = String(await homebridge.request("/getErrorMessage") ?? "");
360
+
361
+ if(signal.aborted) {
362
+
363
+ return;
364
+ }
365
+
366
+ this.#store.dispatch({ message, type: "connection:error" });
367
+
368
+ // The connection-error view reveals #headerInfo itself when it renders the error block (it is the sole owner of the error display, content and reveal together),
369
+ // so the orchestrator only transitions state here and returns. The sidebar, search panel, and config table stay hidden - hide() set them so at show() start and
370
+ // the success-path revealRegions() never runs on this branch - because the user has no devices to navigate to.
371
+ return;
372
+ }
373
+
374
+ // Set the initial scope. Controller-based mode lands on the first controller's controller-as-device entry (devices[0]). Device-only mode lands on global so
375
+ // the user sees the global options first.
376
+ if((initialController !== null) && (devices.length > 0)) {
377
+
378
+ this.#store.dispatch({
379
+
380
+ scope: { controllerId: initialController.serialNumber, deviceId: devices[0].serialNumber, kind: "device" },
381
+ type: "scope:changed"
382
+ });
383
+ } else {
384
+
385
+ // Re-dispatch the global scope to fire the view-options scope-render. The initial state.scope is already global, but views subscribe to scope:changed; without
386
+ // an explicit dispatch, view-options would not run its render path.
387
+ this.#store.dispatch({ scope: { kind: "global" }, type: "scope:changed" });
388
+ }
389
+
390
+ // Reveal regions that views render into.
391
+ revealRegions();
392
+ }
393
+
394
+ /**
395
+ * Drain any debounced-but-unwritten edit to disk, bounded so a stalled host write cannot hang teardown. Shared by every flushed-teardown path - currently
396
+ * `hide()` (navigate-away) and `[Symbol.asyncDispose]` (scope-exit) - because each must flush BEFORE the page signal is aborted: the persist drain guards on it, so
397
+ * aborting first (as the synchronous `cleanup()` does) would re-introduce the very edit-drop this exists to prevent. Races the flush against a non-rejecting
398
+ * {@link FLUSH_TEARDOWN_TIMEOUT_MS} timeout - delay(ms) with no signal simply resolves after ms, so the race settles on whichever finishes first without ever
399
+ * rejecting. On timeout the in-flight commit continues independently and still lands if the host recovers; under that host-stall trade the no-hang guarantee
400
+ * supersedes same-switch Settings-freshness.
401
+ *
402
+ * @returns {Promise<void>}
403
+ */
404
+ async #flushPending() {
405
+
406
+ await Promise.race([ this.#flushPersist?.() ?? Promise.resolve(), delay(FLUSH_TEARDOWN_TIMEOUT_MS) ]);
407
+ }
408
+
409
+ /**
410
+ * Hide the feature options webUI - the navigate-away chokepoint. Flushes any pending edit to disk (see `#flushPending`), then visually hides the regions, then tears
411
+ * down. The flush precedes the teardown so a debounced-but-unwritten edit reaches the host before the page signal aborts.
412
+ *
413
+ * @returns {Promise<void>}
414
+ * @public
415
+ */
416
+ async hide() {
417
+
418
+ await this.#flushPending();
419
+
420
+ for(const id of [ "deviceStatsContainer", "headerInfo", "optionsContainer", "search", "sidebar" ]) {
421
+
422
+ const element = document.getElementById(id);
423
+
424
+ if(element) {
425
+
426
+ element.style.display = "none";
427
+ }
428
+ }
429
+
430
+ this.cleanup();
431
+ }
432
+
433
+ /**
434
+ * Clean up all resources when the instance is no longer needed - immediate, destructive teardown. May drop a debounced-but-unwritten edit: it aborts the page
435
+ * signal without flushing, so any pending drain that guards on the signal bails. Use {@link hide} for the navigate-away path (it flushes first); cleanup() is for
436
+ * forced/synchronous disposal.
437
+ *
438
+ * Aborts the page-level signal (cascading through every effect and view's signal-keyed listeners), nulls the abort controller and the flush handle so the next
439
+ * show() builds fresh ones. Neither the store nor the session is nulled: the `editedConfig` getter remains queryable after hide() / cleanup() so external readers
440
+ * see the last-loaded state rather than an empty array. The next show() replaces the store with a fresh one before any new mutations.
441
+ *
442
+ * @public
443
+ */
444
+ cleanup() {
445
+
446
+ this.#pageAbort?.abort();
447
+ this.#pageAbort = null;
448
+ this.#flushPersist = null;
449
+
450
+ const searchInput = document.getElementById("searchInput");
451
+
452
+ if(searchInput) {
453
+
454
+ searchInput.value = "";
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Explicit-disposable hook. Lets callers use `using orchestrator = new webUiFeatureOptions(...)` to guarantee teardown at scope exit - the runtime calls this
460
+ * automatically when the binding goes out of scope. Equivalent to invoking {@link cleanup} directly.
461
+ *
462
+ * This is the SYNCHRONOUS disposal path: a `using` scope-exit cannot await, so it forfeits the flush and may drop a debounced-but-unwritten edit. That is acceptable
463
+ * for forced teardown, but it is not a navigate-away path - the menu/tab handlers use {@link hide}. When the edit MUST survive scope-exit, prefer `await using` so the
464
+ * runtime selects `[Symbol.asyncDispose]`, which flushes first.
465
+ */
466
+ [Symbol.dispose]() {
467
+
468
+ this.cleanup();
469
+ }
470
+
471
+ /**
472
+ * Async-disposable hook. Lets callers use `await using orchestrator = new webUiFeatureOptions(...)` to guarantee a FLUSHED teardown at scope exit - the runtime awaits
473
+ * this when the binding goes out of scope. The async counterpart of the synchronous `[Symbol.dispose]`: it drains any pending edit via `#flushPending` before tearing
474
+ * down, where the synchronous path cannot await and forfeits the flush. It mirrors `hide()`'s flush-then-teardown ordering minus the region-hiding, which is a
475
+ * navigate-away concern rather than a disposal one.
476
+ *
477
+ * @returns {Promise<void>}
478
+ */
479
+ async [Symbol.asyncDispose]() {
480
+
481
+ await this.#flushPending();
482
+ this.cleanup();
483
+ }
484
+
485
+ /**
486
+ * Default method for retrieving the device list from the Homebridge accessory cache. Plugins override via the constructor's `getDevices` option.
487
+ *
488
+ * Used as the default `getDevices`, it is extracted unbound and later invoked with `#config` as the receiver, so its body must never reference `this` - it reads only
489
+ * the global `homebridge` object. A future edit that needs instance state must bind it explicitly (or stop using it as the bare default).
490
+ *
491
+ * @returns {Promise<Device[]>} The list of devices sorted alphabetically by name.
492
+ * @public
493
+ */
494
+ async getHomebridgeDevices() {
495
+
496
+ const cachedAccessories = await homebridge.getCachedAccessories();
497
+ const devices = [];
498
+
499
+ for(const device of cachedAccessories) {
500
+
501
+ const info = device.services.find((s) => s.constructorName === "AccessoryInformation");
502
+ const getCharValue = (name) => info?.characteristics.find((c) => c.constructorName === name)?.value ?? "";
503
+
504
+ devices.push({
505
+
506
+ firmwareRevision: getCharValue("FirmwareRevision"),
507
+ manufacturer: getCharValue("Manufacturer"),
508
+ model: getCharValue("Model"),
509
+ name: device.displayName,
510
+ serialNumber: getCharValue("SerialNumber")
511
+ });
512
+ }
513
+
514
+ return devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase()));
515
+ }
516
+
517
+ // Mount every view against the active store and page DOM. Each view registers its own listeners with the page signal; nothing here references the views after
518
+ // they are mounted (the store + signal drive their lifecycle).
519
+ #mountViews(signal) {
520
+
521
+ const store = this.#store;
522
+ const headerInfo = document.getElementById("headerInfo");
523
+ const deviceStatsContainer = document.getElementById("deviceStatsContainer");
524
+ const searchPanel = document.getElementById("search");
525
+ const configTable = document.getElementById("configTable");
526
+ const controllersContainer = document.getElementById("controllersContainer");
527
+ const devicesContainer = document.getElementById("devicesContainer");
528
+
529
+ if(headerInfo) {
530
+
531
+ mountHeaderView({ root: headerInfo, signal, store });
532
+
533
+ mountConnectionErrorView({
534
+
535
+ // Retry routes through show(), which owns teardown: its internal `await this.hide()` flushes any debounced edit before aborting the page signal, so a retry
536
+ // cannot drop a pending write. We deliberately do not call cleanup() here - cleanup() aborts the signal without flushing, which is exactly the drop we avoid.
537
+ onRetry: async () => {
538
+
539
+ await this.show(this.#session);
540
+ },
541
+ retryDelayMs: this.#config.controllerRetryEnableDelayMs,
542
+ root: headerInfo,
543
+ signal,
544
+ store
545
+ });
546
+ }
547
+
548
+ if(deviceStatsContainer) {
549
+
550
+ const infoPanel = this.#config.infoPanel;
551
+
552
+ mountDeviceInfoView({
553
+
554
+ infoPanel: infoPanel ? (panel, device) => infoPanel.call(this, device, panel) : undefined,
555
+ root: deviceStatsContainer,
556
+ signal,
557
+ store
558
+ });
559
+ }
560
+
561
+ if(searchPanel && configTable) {
562
+
563
+ mountSearchView({ configTable, root: searchPanel, signal, store });
564
+ }
565
+
566
+ if(configTable) {
567
+
568
+ // The localStorage namespace key is the Homebridge platform identifier - the primary entry's `platform` field, read from the session.
569
+ mountOptionsView({ configTable, platform: this.#session?.platform?.platform, signal, store });
570
+ }
571
+
572
+ if(controllersContainer && devicesContainer) {
573
+
574
+ mountNavView({
575
+
576
+ getDevices: (controller) => this.#devicesFor(controller),
577
+ host: homebridge,
578
+ labelControllers: this.#config.labelControllers,
579
+ labelDevices: this.#config.labelDevices,
580
+ rootControllers: controllersContainer,
581
+ rootDevices: devicesContainer,
582
+ signal,
583
+ store
584
+ });
585
+ }
586
+ }
587
+
588
+ // Resolve the device list for a controller, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. Centralizing
589
+ // the injection here means both the initial fetch in show() and the on-click fetch in the nav view call the same config-fed seam, and the config is read fresh from
590
+ // the session on every call - never captured - so a credential change is always reflected. The default device-only getDevices ignores the injected config.
591
+ #devicesFor(controller) {
592
+
593
+ return this.#config.getDevices(controller, { config: this.#session?.platform });
594
+ }
595
+ }
596
+
597
+ // Update the menu button states to reflect the current page. Swap between the elegant and primary button styles to show active/inactive.
598
+ const updateMenuState = () => {
599
+
600
+ const menuStates = [
601
+
602
+ { id: "menuHome", primary: true },
603
+ { id: "menuFeatureOptions", primary: false },
604
+ { id: "menuSettings", primary: true }
605
+ ];
606
+
607
+ for(const { id, primary } of menuStates) {
608
+
609
+ const element = document.getElementById(id);
610
+
611
+ if(!element) {
612
+
613
+ continue;
614
+ }
615
+
616
+ element.classList.remove(primary ? "btn-elegant" : "btn-primary");
617
+ element.classList.add(primary ? "btn-primary" : "btn-elegant");
618
+ }
619
+ };
620
+
621
+ // Clear stale DOM from any prior cycle. Each region's view repopulates it.
622
+ const clearContainers = () => {
623
+
624
+ for(const id of [ "controllersContainer", "devicesContainer", "configTable" ]) {
625
+
626
+ const container = document.getElementById(id);
627
+
628
+ if(container) {
629
+
630
+ container.textContent = "";
631
+ }
632
+ }
633
+ };
634
+
635
+ // Reveal the regions every view renders into. The hide() path set these to display: none so the user did not see a half-built UI during teardown / rebuild.
636
+ const revealRegions = () => {
637
+
638
+ for(const id of [ "sidebar", "deviceStatsContainer", "headerInfo", "optionsContainer", "search" ]) {
639
+
640
+ const element = document.getElementById(id);
641
+
642
+ if(element) {
643
+
644
+ element.style.display = "";
645
+ }
646
+ }
647
+ };
648
+
649
+ // Set-wise equality on two string arrays. Used to decide whether a re-loaded options array represents a genuine save (different set) or a no-op reorder (same set).
650
+ // O(n) via Set.symmetricDifference; duplicate-insensitive matches buildConfigIndex's first-write-wins semantics for the configured-options array it operates over.
651
+ const sameOptionsSet = (a, b) => {
652
+
653
+ if(a === b) {
654
+
655
+ return true;
656
+ }
657
+
658
+ if(a.length !== b.length) {
659
+
660
+ return false;
661
+ }
662
+
663
+ return new Set(a).symmetricDifference(new Set(b)).size === 0;
664
+ };
665
+
666
+ // Show the "no controllers configured" helper text when the plugin operates in controller-based mode but getControllers returns an empty result. Replaces the
667
+ // header content in place; the orchestrator returns from show() without dispatching model:loaded so views never mount.
668
+ const showNoControllersMessage = () => {
669
+
670
+ const headerInfo = document.getElementById("headerInfo");
671
+
672
+ if(headerInfo) {
673
+
674
+ headerInfo.textContent = "Please configure a controller to access in the main settings tab before configuring feature options.";
675
+ headerInfo.style.display = "";
676
+ }
677
+ };