homebridge-plugin-utils 2.0.0 → 2.2.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 (99) hide show
  1. package/build/tsconfig.json +15 -0
  2. package/dist/backpressure.d.ts +1 -1
  3. package/dist/backpressure.js +4 -4
  4. package/dist/backpressure.js.map +1 -1
  5. package/dist/cli/index.d.ts +93 -5
  6. package/dist/cli/index.js +296 -30
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/clock-double.js +2 -2
  9. package/dist/clock-double.js.map +1 -1
  10. package/dist/disposable-stack.d.ts +59 -0
  11. package/dist/disposable-stack.js +155 -0
  12. package/dist/disposable-stack.js.map +1 -0
  13. package/dist/docChrome.d.ts +263 -0
  14. package/dist/docChrome.js +364 -0
  15. package/dist/docChrome.js.map +1 -0
  16. package/dist/featureOptions-docs.d.ts +2 -2
  17. package/dist/featureOptions-docs.js +1 -1
  18. package/dist/featureOptions.d.ts +5 -5
  19. package/dist/featureOptions.js +6 -6
  20. package/dist/ffmpeg/exec.d.ts +2 -2
  21. package/dist/ffmpeg/exec.js +2 -2
  22. package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
  23. package/dist/ffmpeg/fmp4-builders.js +163 -0
  24. package/dist/ffmpeg/fmp4-builders.js.map +1 -0
  25. package/dist/ffmpeg/index.d.ts +1 -0
  26. package/dist/ffmpeg/index.js +1 -0
  27. package/dist/ffmpeg/index.js.map +1 -1
  28. package/dist/ffmpeg/mp4-assembler.d.ts +45 -4
  29. package/dist/ffmpeg/mp4-assembler.js +45 -8
  30. package/dist/ffmpeg/mp4-assembler.js.map +1 -1
  31. package/dist/ffmpeg/mp4-parser.d.ts +1 -1
  32. package/dist/ffmpeg/mp4-parser.js +1 -1
  33. package/dist/ffmpeg/options.d.ts +17 -0
  34. package/dist/ffmpeg/options.js +50 -25
  35. package/dist/ffmpeg/options.js.map +1 -1
  36. package/dist/ffmpeg/process.d.ts +1 -1
  37. package/dist/ffmpeg/process.js +4 -4
  38. package/dist/ffmpeg/process.js.map +1 -1
  39. package/dist/ffmpeg/record.d.ts +26 -1
  40. package/dist/ffmpeg/record.js +12 -0
  41. package/dist/ffmpeg/record.js.map +1 -1
  42. package/dist/ffmpeg/recording-process-double.d.ts +13 -0
  43. package/dist/ffmpeg/recording-process-double.js +22 -0
  44. package/dist/ffmpeg/recording-process-double.js.map +1 -1
  45. package/dist/ffmpeg/rtp-parser.d.ts +1 -1
  46. package/dist/ffmpeg/rtp-parser.js +1 -1
  47. package/dist/ffmpeg/rtp.d.ts +4 -4
  48. package/dist/ffmpeg/rtp.js +7 -6
  49. package/dist/ffmpeg/rtp.js.map +1 -1
  50. package/dist/ffmpeg/stream.js +1 -1
  51. package/dist/ffmpeg/stream.js.map +1 -1
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.js +3 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/logclient/auth.d.ts +3 -3
  56. package/dist/logclient/auth.js +2 -2
  57. package/dist/logclient/auth.js.map +1 -1
  58. package/dist/logclient/cli-run.js +2 -2
  59. package/dist/logclient/cli-run.js.map +1 -1
  60. package/dist/logclient/cli.js +2 -2
  61. package/dist/logclient/client.js +2 -2
  62. package/dist/logclient/client.js.map +1 -1
  63. package/dist/logclient/rest.js +1 -1
  64. package/dist/logclient/socket-double.d.ts +1 -1
  65. package/dist/logclient/socket-double.js +1 -1
  66. package/dist/logclient/socket.js +5 -5
  67. package/dist/logclient/socket.js.map +1 -1
  68. package/dist/logclient/time-window.d.ts +1 -1
  69. package/dist/mqttClient.d.ts +1 -1
  70. package/dist/mqttClient.js +3 -3
  71. package/dist/mqttClient.js.map +1 -1
  72. package/dist/timer-registry.d.ts +100 -0
  73. package/dist/timer-registry.js +184 -0
  74. package/dist/timer-registry.js.map +1 -0
  75. package/dist/ui/featureOptions.js +6 -6
  76. package/dist/ui/pluginConfigSession.mjs +1 -1
  77. package/dist/ui/webUi-featureOptions/effects/persist.mjs +2 -2
  78. package/dist/ui/webUi-featureOptions/effects/theme.mjs +1 -1
  79. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +1 -1
  80. package/dist/ui/webUi-featureOptions/rendering.mjs +2 -2
  81. package/dist/ui/webUi-featureOptions/selectors.mjs +3 -3
  82. package/dist/ui/webUi-featureOptions/state.mjs +72 -15
  83. package/dist/ui/webUi-featureOptions/store.mjs +1 -1
  84. package/dist/ui/webUi-featureOptions/utils.mjs +25 -1
  85. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +28 -11
  86. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
  87. package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
  88. package/dist/ui/webUi-featureOptions/views/nav.mjs +39 -32
  89. package/dist/ui/webUi-featureOptions/views/options.mjs +13 -7
  90. package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
  91. package/dist/ui/webUi-featureOptions.mjs +109 -61
  92. package/dist/ui/webUi.mjs +48 -11
  93. package/dist/util.d.ts +118 -16
  94. package/dist/util.js +96 -14
  95. package/dist/util.js.map +1 -1
  96. package/dist/webui-loader.d.ts +80 -0
  97. package/dist/webui-loader.js +373 -0
  98. package/dist/webui-loader.js.map +1 -0
  99. package/package.json +10 -10
@@ -13,10 +13,10 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
13
13
  * and derives its view via selectors. Discriminated unions encode the variant types the UI moves through, each listed below:
14
14
  *
15
15
  * - {@link Scope} - `{kind: "global"}` | `{kind: "controller", controllerId}` | `{kind: "device", controllerId, deviceId}`. The selection pointer. Discriminated
16
- * because each kind carries different data; merging them into a flat record would smear the invariants across two fields and force consumers to recover the
16
+ * because each kind carries different data; merging them into a flat record would smear the guarantees across two fields and force consumers to recover the
17
17
  * kind via predicates.
18
18
  * - {@link LifecycleStatus} - `loading` | `ready` | `persisting` | `persist-error` | `connection-error`. The page-state pointer. Discriminated because the
19
- * variants carry different per-state payloads (a snapshot when persisting, an error when failed, a message when the connection broke).
19
+ * variants carry different per-state payloads (a snapshot when persisting, an error when failed, the full display copy when the connection broke).
20
20
  * - {@link Catalog} - `CatalogIndex` (from featureOptions.ts) extended with plugin-provided validator callbacks. Bundled as one value because both the index and
21
21
  * the validators are plugin-provided immutable config moving together; splitting them would force every consumer that needs both to take two parameters.
22
22
  *
@@ -25,7 +25,10 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
25
25
  *
26
26
  * - `model:loaded` - first load: catalog, configuredOptions, controllers, mode are populated; status transitions to ready.
27
27
  * - `controllers:loaded` - controllers-only refresh (reducer + nav subscriber wired) that no code currently dispatches; the retry path re-runs the full `model:loaded`.
28
- * - `devices:loaded` - devices list updated when the active controller changes or device-only mode resolves the accessory cache.
28
+ * - `devices:requested` - a device fetch is beginning: mints the next fetch sequence into state and records it as the pending request, so the outcome that
29
+ * eventually answers it can be told apart from a superseded one.
30
+ * - `devices:loaded` - a device fetch's outcome - its device list and connection error - stamped with the sequence its request minted. Applies only when it answers
31
+ * the pending request; a superseded or seq-less outcome is dropped at this chokepoint. A non-empty error also transitions status to connection-error.
29
32
  * - `scope:changed` - selection pointer moved (global / controller / device).
30
33
  * - `option:set` - single option enabled/disabled (with optional value) at some scope.
31
34
  * - `option:cleared` - single option removed at some scope.
@@ -35,7 +38,7 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
35
38
  * - `persist:started` - persist call entering flight; status becomes persisting.
36
39
  * - `persist:succeeded` - persist call landed on disk; anchor updated, status returns to ready.
37
40
  * - `persist:failed` - final-attempt failure (no superseding mutation); configuredOptions rolls back to anchor, status becomes persist-error.
38
- * - `connection:error` - controller unreachable on the current view; status becomes connection-error with the user-facing message.
41
+ * - `connection:error` - the config re-sync failed before the page could render; status becomes connection-error carrying the full display copy the view renders.
39
42
  *
40
43
  * The reducer is pure: `(state, action) => state`. Unchanged slices retain their reference across dispatches (structural sharing), so memoized selectors that
41
44
  * depend on a slice return cached results until that specific slice changes. Unknown action types throw - silently ignoring them would let typo bugs escape into
@@ -63,7 +66,7 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
63
66
 
64
67
  /**
65
68
  * @typedef {Object} Validators
66
- * @property {(device: Device) => boolean} isController - Predicate for "is this device a controller-as-device" (drives controller-vs-device scope discrimination).
69
+ * @property {(device: Device) => boolean} isController - Predicate for "is this device a controller-as-device" (drives controller-vs-device scope distinction).
67
70
  * @property {(device: Device | undefined, option: Object) => boolean} validOption - Predicate for "should this option render for this device."
68
71
  * @property {(device: Device | undefined, category: Object) => boolean} validOptionCategory - Predicate for "should this category render for this device."
69
72
  */
@@ -100,8 +103,12 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
100
103
  * LifecycleStatus - The page-state pointer. Discriminated because the variants carry different per-state payloads. Drop a status variant when it stops being a
101
104
  * named UI state; add one when a new named state surfaces.
102
105
  *
106
+ * The `connection-error` variant carries its full display copy - `headline`, `guidance`, and `message` - so the connection-error view maps three text slots without
107
+ * hardcoding any prose. The two suppliers (the reducer's fetch-failure transition on {@link devices:loaded} and the orchestrator's config-sync-failure
108
+ * {@link connection:error} dispatch) each carry copy appropriate to their failure.
109
+ *
103
110
  * @typedef {{kind: "loading"} | {kind: "ready"} | {kind: "persisting", snapshot: readonly string[]} | {kind: "persist-error", error: Error}
104
- * | {kind: "connection-error", message: string}} LifecycleStatus
111
+ * | {kind: "connection-error", guidance: string, headline: string, message: string}} LifecycleStatus
105
112
  */
106
113
 
107
114
  /**
@@ -113,10 +120,17 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
113
120
  * @property {readonly string[]} configuredOptions - The canonical user-state array. Mutations replace it via the pure transforms from featureOptions.ts.
114
121
  * @property {readonly Controller[]} controllers - Controllers list (empty in device-only mode or before resolution).
115
122
  * @property {readonly Device[]} devices - Devices list for the active controller (or the cached-accessories list in device-only mode).
123
+ * @property {number} devicesAppliedSeq - The sequence of the device-fetch outcome currently applied. The reducer's own verdict fact: a dispatcher reads it back to
124
+ * learn whether its outcome (the one carrying this sequence) is the one that landed, gating its follow-up work on one integer comparison rather than on the
125
+ * device array's reference, which the shared-empty-array idiom and a caching `getDevices` can alias across fetches.
116
126
  * @property {string | null} devicesControllerId - Serial of the controller whose `devices` are loaded, or null (device-only / none yet). Preserves the
117
127
  * device-to-controller association that `scope` drops once the selection goes global, so a loaded device's parent controller stays resolvable after the
118
128
  * selection leaves controller scope.
119
- * @property {{mode: "all" | "modified", query: string}} filter - Search and filter state. Two-field record because the dimensions are orthogonal (every combination
129
+ * @property {{controllerId: string | null, seq: number} | null} devicesRequest - The pending device-fetch record, or null when no fetch is outstanding. A
130
+ * `devices:requested` records the latest fetch here; the `devices:loaded` that carries the same sequence clears it, and any other outcome is dropped.
131
+ * @property {number} devicesRequestSeq - The persistent monotonic fetch counter. Never reset within a store's life, so every fetch across the session gets a unique,
132
+ * increasing sequence and last-request-wins holds even for two fetches against the same controller.
133
+ * @property {{mode: "all" | "modified", query: string}} filter - Search and filter state. Two-field record because the dimensions are independent (every combination
120
134
  * is valid and meaningful).
121
135
  * @property {readonly string[]} initialOptions - The at-show() snapshot for "Revert to Saved." Stable across the session except when re-show() loads an option
122
136
  * set that is not set-equal to the prior snapshot, in which case the snapshot is replaced.
@@ -143,6 +157,16 @@ const EMPTY_CATALOG = {
143
157
  }
144
158
  };
145
159
 
160
+ // The connection-error display copy for a controller fetch failure. The reducer supplies it at its one fetch-failure transition (on devices:loaded), so this
161
+ // controller wording lives here rather than being hardcoded in the connection-error view, which maps every text slot from the status. The per-fetch failure
162
+ // message travels back on the outcome and is layered on as `message`.
163
+ const CONTROLLER_FAILURE_STATUS = {
164
+
165
+ guidance: "Check the Settings tab to verify the controller details are correct.",
166
+ headline: "Unable to connect to the controller.",
167
+ kind: "connection-error"
168
+ };
169
+
146
170
  /**
147
171
  * Build the initial state. Status is `loading`; every populated-at-runtime field is set to an empty array or default value. The first {@link model:loaded}
148
172
  * dispatch transitions every field to its loaded value in one atomic update.
@@ -157,7 +181,7 @@ export const initialState = () => {
157
181
 
158
182
  // Share a single empty-array reference across every array-typed option field so the persist effect's "are we dirty?" check (configuredOptions === persistedAnchor)
159
183
  // returns true at registration time and does not trigger a spurious initial persist. After model:loaded, each field is set to the loaded array's reference; the
160
- // invariant is preserved.
184
+ // guarantee is preserved.
161
185
  const empty = [];
162
186
 
163
187
  return {
@@ -166,7 +190,10 @@ export const initialState = () => {
166
190
  configuredOptions: empty,
167
191
  controllers: [],
168
192
  devices: [],
193
+ devicesAppliedSeq: 0,
169
194
  devicesControllerId: null,
195
+ devicesRequest: null,
196
+ devicesRequestSeq: 0,
170
197
  filter: { mode: "all", query: "" },
171
198
  initialOptions: empty,
172
199
  mode: "device-only",
@@ -215,12 +242,42 @@ export const reducer = (state, action) => {
215
242
  return { ...state, controllers: action.controllers };
216
243
  }
217
244
 
245
+ case "devices:requested": {
246
+
247
+ // Mint the next monotonic fetch sequence and record it as the pending request. The sequence - not the controllerId - is the fetch identity, so two in-flight
248
+ // fetches for the same controller (a re-click, or a click racing the initial fetch) still resolve last-request-wins. The latest request owns the pending slot;
249
+ // an earlier in-flight fetch's outcome finds its sequence superseded when it lands.
250
+ const seq = state.devicesRequestSeq + 1;
251
+
252
+ return { ...state, devicesRequest: { controllerId: action.controllerId ?? null, seq }, devicesRequestSeq: seq };
253
+ }
254
+
218
255
  case "devices:loaded": {
219
256
 
220
- // Devices list updated when the active controller changes or device-only mode resolves the cached-accessories list. `devicesControllerId` records which
221
- // controller these devices belong to (null in device-only mode), so the association survives a later move to global scope. Scope is not touched here - the
222
- // caller dispatches `scope:changed` separately if the selection needs to move.
223
- return { ...state, devices: action.devices, devicesControllerId: action.controllerId ?? null };
257
+ // A device fetch's outcome, stamped with the sequence its `devices:requested` minted. Apply it only when it answers the pending request; a superseded or
258
+ // seq-less outcome vanishes here so a stale continuation cannot clobber the current view, and tests can assert reference equality on the dropped path. The null
259
+ // check is explicit: an optional-chained comparison would read undefined on both sides for a seq-less action against no pending request and wrongly apply it,
260
+ // silently green-lighting an unpaired legacy fixture.
261
+ if((state.devicesRequest === null) || (action.seq !== state.devicesRequest.seq)) {
262
+
263
+ return state;
264
+ }
265
+
266
+ // The pending request is answered. Record the applied sequence as the reducer's own verdict fact (dispatchers read it back to gate their follow-ups), clear the
267
+ // pending slot, and adopt the device list with its owning controller (null in device-only mode) so the association survives a later move to global scope.
268
+ const applied = {
269
+
270
+ ...state,
271
+ devices: action.devices,
272
+ devicesAppliedSeq: action.seq,
273
+ devicesControllerId: action.controllerId ?? null,
274
+ devicesRequest: null
275
+ };
276
+
277
+ // A non-empty error is the connection-failure signal: the outcome carried an empty device list and the per-fetch failure message alongside it, so the status
278
+ // moves to connection-error at this, the reducer's one fetch-failure transition, layering the message onto the shared controller-failure copy. Scope is not
279
+ // touched here - a dispatcher moves the selection separately.
280
+ return action.error.length ? { ...applied, status: { ...CONTROLLER_FAILURE_STATUS, message: action.error } } : applied;
224
281
  }
225
282
 
226
283
  case "scope:changed": {
@@ -304,9 +361,9 @@ export const reducer = (state, action) => {
304
361
 
305
362
  case "connection:error": {
306
363
 
307
- // Controller unreachable on the current view. The orchestrator's connection-error flow dispatches this after fetching the user-facing message from
308
- // `/getErrorMessage`; subscribers (status bar / sidebar) consume the message.
309
- return { ...state, status: { kind: "connection-error", message: action.message } };
364
+ // The config re-sync failed before the page could render. The orchestrator dispatches this with the full display copy for the connection-error view (headline,
365
+ // guidance, message); the reducer's own fetch-failure transition on devices:loaded is the other supplier of this variant.
366
+ return { ...state, status: { guidance: action.guidance, headline: action.headline, kind: "connection-error", message: action.message } };
310
367
  }
311
368
 
312
369
  default: {
@@ -24,7 +24,7 @@ const ModuleCustomEvent = CustomEvent;
24
24
  * cleanup follows the caller's {@link AbortSignal} automatically, no `destroy()` ceremony required. The signal-keyed lifecycle is the same primitive every
25
25
  * other part of the webUI already uses for its DOM listeners, so the store's subscription model integrates without a new lifecycle concept.
26
26
  * - **Read-then-dispatch ordering.** The reducer applies before the event fires, so a subscriber reading `store.state` from inside its handler always sees the
27
- * post-dispatch state. The ordering is load-bearing: views that re-derive their DOM in response to an event must see the new state, not the pre-mutation
27
+ * post-dispatch state. The ordering matters: views that re-derive their DOM in response to an event must see the new state, not the pre-mutation
28
28
  * state that triggered the dispatch.
29
29
  *
30
30
  * The store carries no domain logic itself - the reducer (passed at construction) and the action vocabulary (consumed by subscribers) are the application-specific
@@ -1,6 +1,6 @@
1
1
  /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
2
  *
3
- * webUi-featureOptions/utils.mjs: Shared utilities for the feature options webUI components.
3
+ * webUi-featureOptions/utils.mjs: Shared utilities for the plugin webUI - used by the feature-options components and the top-level orchestrator alike.
4
4
  */
5
5
  "use strict";
6
6
 
@@ -198,3 +198,27 @@ export function showToast(message, variant = "alert-success") {
198
198
  setTimeout(() => toast.remove(), 150);
199
199
  }, 3000);
200
200
  }
201
+
202
+ /**
203
+ * Extract a user-facing message from an arbitrary thrown value. The webUI's extension points - caller-supplied first-run hooks, plugin device fetchers, the
204
+ * connection-error retry callback, the config re-sync - can reject with any shape (an Error, a string, a plain object, a primitive), so the message is extracted
205
+ * defensively: `err?.message` when the value carries one, a string coercion of the whole value otherwise. This is the single error-to-text truth the webUI shares,
206
+ * so a toast, a nav-view connection-error message, and the sync-failure copy all read the same thrown value the same way.
207
+ *
208
+ * @param {*} err - The thrown value to describe.
209
+ * @returns {string} The extracted message.
210
+ */
211
+ export function errorMessage(err) {
212
+
213
+ return err?.message ?? String(err);
214
+ }
215
+
216
+ /**
217
+ * Surface an arbitrary thrown value as an error toast. Routes the value through {@link errorMessage} so the toast text stays useful regardless of what bubbled out.
218
+ *
219
+ * @param {*} err - The thrown value to surface.
220
+ */
221
+ export function toastError(err) {
222
+
223
+ homebridge.toast.error(errorMessage(err), "Error");
224
+ }
@@ -10,15 +10,16 @@ import { effect } from "../store.mjs";
10
10
  /**
11
11
  * Mount the connection-error view.
12
12
  *
13
- * Subscribes to `connection:error` and `model:loaded`. On `model:loaded` this view yields - it aborts its retry window and stops rendering - and the shared
14
- * `#headerInfo` container is reclaimed by the header view; it does not itself clear the error display.
13
+ * Subscribes to `connection:error`, `devices:loaded`, and `model:loaded`. On `model:loaded` this view yields - it aborts its retry window and stops rendering - and the
14
+ * shared `#headerInfo` container is reclaimed by the header view; it does not itself clear the error display.
15
15
  *
16
- * Renders into the same `#headerInfo` container the priority-chain header uses. The two views coordinate via the `state.status` discriminator: header yields when
16
+ * Renders into the same `#headerInfo` container the priority-chain header uses. The two views coordinate via the `state.status` tag: header yields when
17
17
  * status is connection-error; this view yields when status is anything else.
18
18
  *
19
19
  * Renders:
20
20
  *
21
- * - An error message block with the user-facing message from `state.status.message`.
21
+ * - An error block whose headline, guidance, and message all come from the `connection-error` status. The caller - the reducer's fetch-failure transition or the
22
+ * orchestrator's config-sync-failure dispatch - supplies the full display copy, so this view maps the three text slots without hardcoding any prose.
22
23
  * - A retry button, initially disabled, that becomes enabled after `retryDelayMs` milliseconds. The delay is a brief throttle so the user does not retry-bash a
23
24
  * recovering controller.
24
25
  * - A progress bar that fills during the retry-delay window so the user has visual feedback that the retry button is coming alive.
@@ -38,13 +39,28 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
38
39
  // retry button does not linger after the user navigates away.
39
40
  let retryAbort = null;
40
41
 
42
+ // The last status object this view acted on. The reducer mints a new status object only on a genuine transition, so a `devices:loaded` that did not move the status
43
+ // leaves this reference unchanged and the effect below skips - a dropped or successful device outcome neither tears down an armed retry window nor restarts its
44
+ // progress animation.
45
+ let lastStatus;
46
+
41
47
  effect({
42
48
 
43
- events: [ "connection:error", "model:loaded" ],
49
+ events: [ "connection:error", "devices:loaded", "model:loaded" ],
44
50
  fn: () => {
45
51
 
46
52
  const { status } = store.state;
47
53
 
54
+ // Skip when the status is reference-identical to the one already acted on. The subscription includes `devices:loaded` because the reducer folds its
55
+ // fetch-failure transition into that action - without the subscription the folded error would never render the retry UI - and this guard keeps a successful or
56
+ // dropped device outcome, which does not touch the status, from resetting the retry window that a live connection-error is showing.
57
+ if(status === lastStatus) {
58
+
59
+ return;
60
+ }
61
+
62
+ lastStatus = status;
63
+
48
64
  // Tear down any prior retry window before either rendering a new one or yielding back to the header view.
49
65
  retryAbort?.abort();
50
66
  retryAbort = null;
@@ -55,7 +71,7 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
55
71
  }
56
72
 
57
73
  retryAbort = new AbortController();
58
- renderError({ message: status.message, onRetry, retryDelayMs, retrySignal: retryAbort.signal, root });
74
+ renderError({ guidance: status.guidance, headline: status.headline, message: status.message, onRetry, retryDelayMs, retrySignal: retryAbort.signal, root });
59
75
  },
60
76
  signal,
61
77
  store
@@ -68,15 +84,16 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
68
84
  }, { once: true });
69
85
  };
70
86
 
71
- // Render the error block into the root container. Builds the structural pieces (error text, retry button, progress bar) and arms the retry window via the
72
- // supplied retry signal.
73
- const renderError = ({ message, onRetry, retryDelayMs, retrySignal, root }) => {
87
+ // Render the error block into the root container. Builds the structural pieces (headline, guidance, the failure message, retry button, progress bar) and arms the
88
+ // retry window via the supplied retry signal. The three text slots - headline, guidance, message - are caller-supplied through the status, so this view holds no
89
+ // hardcoded failure prose of its own.
90
+ const renderError = ({ guidance, headline, message, onRetry, retryDelayMs, retrySignal, root }) => {
74
91
 
75
92
  const errorBlock = createElement("div", {}, [
76
93
 
77
- "Unable to connect to the controller.",
94
+ headline,
78
95
  createElement("br"),
79
- "Check the Settings tab to verify the controller details are correct.",
96
+ guidance,
80
97
  createElement("br"),
81
98
  createElement("code", { classList: ["text-danger"] }, [message]),
82
99
  createElement("br")
@@ -34,6 +34,13 @@ export const mountDeviceInfoView = ({ infoPanel = defaultInfoPanel, root, signal
34
34
  events: [ "scope:changed", "devices:loaded", "model:loaded" ],
35
35
  fn: () => {
36
36
 
37
+ // Skip the pre-model mount. The orchestrator mounts every view before model:loaded fires, so this view's immediate-run pass would otherwise render against the
38
+ // loading placeholder - work the model:loaded pass immediately redoes. The sibling views carry the same guard.
39
+ if(store.state.status.kind === "loading") {
40
+
41
+ return;
42
+ }
43
+
37
44
  // The view populates its region but never reveals it; the orchestrator owns region visibility (revealRegions on the success path), so the device-stats panel
38
45
  // appears together with the rest of the populated UI rather than the moment this view mounts.
39
46
  render(infoPanel);
@@ -14,7 +14,8 @@ import { effect } from "../store.mjs";
14
14
  * device-only mode. Bold lead-in text frames it as a precedence statement; color-coded labels (warning / success / info) match the same scope-color convention the
15
15
  * row labels use, so users have one consistent visual lens for "where does a setting come from."
16
16
  *
17
- * Runs on `model:loaded` and `connection:error`; all other dispatches never invoke it because they are not subscribed. On `connection:error` (and the loading
17
+ * Runs on `model:loaded`, `connection:error`, and `devices:loaded`; other dispatches never invoke it because they are not subscribed, and a subscribed dispatch
18
+ * that leaves the status reference unchanged skips via the memo below. On `connection:error` (and the loading
18
19
  * status), `fn` yields inside its `status.kind` checks. In practice the header content is rendered once, at `model:loaded`. The connection-error and no-controllers
19
20
  * views render their own content into the same container, so this view yields when the status indicates either of those states.
20
21
  *
@@ -25,22 +26,38 @@ import { effect } from "../store.mjs";
25
26
  */
26
27
  export const mountHeaderView = ({ root, signal, store }) => {
27
28
 
29
+ // The last status object this view acted on. The reducer mints a new status object only on a genuine transition, so a `devices:loaded` that did not move the status
30
+ // (a successful fetch, or a dropped stale outcome that returns the identical state) leaves this reference unchanged and the effect below skips - the precedence
31
+ // chain is never rebuilt for a device-list change it does not depend on.
32
+ let lastStatus;
33
+
28
34
  effect({
29
35
 
30
- events: [ "model:loaded", "connection:error" ],
36
+ events: [ "connection:error", "devices:loaded", "model:loaded" ],
31
37
  fn: () => {
32
38
 
33
39
  const { mode, status } = store.state;
34
40
 
41
+ // Skip when the status is reference-identical to the one already acted on: this view renders from `mode` and `status`, and `mode` is fixed after model:loaded,
42
+ // so an unchanged status means nothing to redo. The subscription includes `devices:loaded` because the reducer folds its fetch-failure transition into that
43
+ // action - without the subscription the header would never yield to the connection-error view on a failed fetch - and this guard keeps every successful or
44
+ // dropped device outcome from needlessly rebuilding the chain.
45
+ if(status === lastStatus) {
46
+
47
+ return;
48
+ }
49
+
50
+ lastStatus = status;
51
+
35
52
  // Yield to the connection-error view when an error is active - that view owns the header content in error states.
36
53
  if(status.kind === "connection-error") {
37
54
 
38
55
  return;
39
56
  }
40
57
 
41
- // Defensive guard against a "loading" status at mount time. In the current call order this branch is unreachable: the no-controllers path returns from
42
- // show() before any views mount, and the success path dispatches model:loaded - which sets status to ready - before mountHeaderView runs. The check stays
43
- // in place as a safeguard against a future reordering that mounts views before model:loaded fires.
58
+ // Yield on the "loading" status at mount time. The orchestrator mounts every view before model:loaded fires - so the connection-error view exists to render a
59
+ // sync failure - which means this view's immediate-run pass sees the loading placeholder and must not render the precedence chain against an empty model. The
60
+ // model:loaded dispatch fires this effect again with a ready status, which is when the chain actually renders.
44
61
  if(status.kind === "loading") {
45
62
 
46
63
  return;
@@ -4,7 +4,7 @@
4
4
  */
5
5
  "use strict";
6
6
 
7
- import { createElement } from "../utils.mjs";
7
+ import { createElement, errorMessage } from "../utils.mjs";
8
8
  import { effect } from "../store.mjs";
9
9
 
10
10
  /**
@@ -28,23 +28,25 @@ import { effect } from "../store.mjs";
28
28
  * - `scope:changed` - update active-link highlighting without rebuilding.
29
29
  * - `model:loaded` - initial build (controllers + global link + mode-aware structure).
30
30
  *
31
- * The controller-click handler does I/O: it calls the caller-supplied `getDevices` callback to fetch the new controller's devices, then dispatches a
32
- * `devices:loaded` action. The dispatch is wrapped in a try/catch so a failing fetch surfaces as a `connection:error` action; the view layer never silently
33
- * swallows a network failure.
31
+ * The controller-click handler does I/O: it records the fetch at the store (`devices:requested`, which mints the fetch sequence), calls the caller-supplied
32
+ * `getDevices` callback for the new controller's DeviceListResult, then stamps the outcome onto a `devices:loaded` carrying that sequence. The reducer applies the
33
+ * outcome only when it still answers the pending request, so the sequence is the fetch identity and the newest click owns the store: a superseded controller click's
34
+ * outcome - whether it resolved with devices or rejected - is dropped at the reducer rather than overwriting the newer click's rendered state. A failed fetch's
35
+ * message travels back on that same `devices:loaded` (empty devices, non-empty error), which the reducer turns into the connection-error transition. The handler
36
+ * wraps its fetch in a try/catch so a rejected fetch becomes that same outcome rather than an unhandled rejection; the view layer never silently swallows a failure.
34
37
  *
35
38
  * @param {Object} args
36
39
  * @param {((controller: import("../state.mjs").Controller | null) =>
37
- * Promise<readonly import("../state.mjs").Device[]>) | undefined} args.getDevices
38
- * - Plugin-provided fetcher for a controller's devices. Called on controller-link click.
40
+ * Promise<import("../../webUi-featureOptions.mjs").DeviceListResult>) | undefined} args.getDevices
41
+ * - Plugin-provided fetcher resolving a controller's DeviceListResult. Called on controller-link click.
39
42
  * @param {string} args.labelControllers - Section header label for the controllers list.
40
43
  * @param {string} args.labelDevices - Section header label for the devices list.
41
- * @param {{ request: (path: string) => Promise<unknown> }} args.host - Homebridge bridge (used to fetch the error message on connection failure).
42
44
  * @param {HTMLElement} args.rootControllers - The `#controllersContainer` element.
43
45
  * @param {HTMLElement} args.rootDevices - The `#devicesContainer` element.
44
46
  * @param {AbortSignal} args.signal - Lifecycle signal.
45
47
  * @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
46
48
  */
47
- export const mountNavView = ({ getDevices, host, labelControllers, labelDevices, rootControllers, rootDevices, signal, store }) => {
49
+ export const mountNavView = ({ getDevices, labelControllers, labelDevices, rootControllers, rootDevices, signal, store }) => {
48
50
 
49
51
  // Controllers container rebuilds on model:loaded (initial mode/controllers), plus controllers:loaded - a controllers-only refresh hook not currently dispatched.
50
52
  effect({
@@ -96,8 +98,9 @@ export const mountNavView = ({ getDevices, host, labelControllers, labelDevices,
96
98
  store
97
99
  });
98
100
 
99
- // Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change.
100
- const onClick = (event) => handleNavClick({ event, getDevices, host, signal, store });
101
+ // Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change. The
102
+ // last-request-wins race a controller click can open is owned by the reducer's fetch sequence, so the handler holds no per-mount generation state of its own.
103
+ const onClick = (event) => handleNavClick({ event, getDevices, signal, store });
101
104
 
102
105
  rootControllers.addEventListener("click", onClick, { signal });
103
106
  rootDevices.addEventListener("click", onClick, { signal });
@@ -122,7 +125,7 @@ const sectionHeader = (label) => createElement("h6", {
122
125
  }, [label]);
123
126
 
124
127
  // Append a labeled section to a container: an optional header followed by one rendered node per item. The header renders only when the section has at least one item,
125
- // so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section" invariant - it makes an orphan
128
+ // so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section" rule - it makes an orphan
126
129
  // header (a label with no items beneath it) unrepresentable regardless of which list a caller renders, which is what keeps a fully-grouped device set from showing a
127
130
  // standalone top-level header that labels nothing.
128
131
  const appendSection = ({ items, label, render, root }) => {
@@ -164,7 +167,7 @@ const buildControllersList = ({ controllerLabel, mode, root, state }) => {
164
167
 
165
168
  // The controllers section. In controller-based mode there is always at least one controller by the time the sidebar builds (the orchestrator shows the
166
169
  // no-controllers message and never mounts the nav otherwise), so the non-empty guard is belt-and-suspenders here - but routing through appendSection keeps every
167
- // section under one invariant rather than special-casing this one.
170
+ // section under one rule rather than special-casing this one.
168
171
  appendSection({
169
172
 
170
173
  items: state.controllers,
@@ -175,7 +178,7 @@ const buildControllersList = ({ controllerLabel, mode, root, state }) => {
175
178
  };
176
179
 
177
180
  // Build the devices container. The ungrouped devices form the top-level section under the device label; each sidebarGroup forms its own section in alphabetical order.
178
- // Because the device-label header renders only when there is at least one ungrouped device (the appendSection invariant), a fully-grouped device set - every device
181
+ // Because the device-label header renders only when there is at least one ungrouped device (the appendSection rule), a fully-grouped device set - every device
179
182
  // carrying a sidebarGroup - shows its group headers alone, with no orphan top-level device header. Controllers are excluded from group derivation (their link lives in
180
183
  // the controllers container above); the reserved "hidden" group excludes devices from the sidebar entirely.
181
184
  const buildDevicesList = ({ catalog, deviceLabel, devices, root }) => {
@@ -238,8 +241,8 @@ const applyDevicesHighlight = (root, scope) => {
238
241
  };
239
242
 
240
243
  // Handle a click on any nav link. Resolves the click target's `data-navigation` and dispatches the corresponding scope-change. Controller clicks additionally
241
- // fetch the new controller's devices via the caller-supplied `getDevices` callback.
242
- const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
244
+ // fetch the new controller's DeviceListResult via the caller-supplied `getDevices` callback.
245
+ const handleNavClick = async ({ event, getDevices, signal, store }) => {
243
246
 
244
247
  const navLink = event.target.closest(".nav-link[data-navigation]");
245
248
 
@@ -264,8 +267,8 @@ const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
264
267
 
265
268
  case "controller": {
266
269
 
267
- // Optimistic scope update before the fetch so the sidebar highlight repaints immediately. If the fetch fails we transition to connection:error; if it
268
- // succeeds we dispatch devices:loaded and select the controller-as-device entry (the first device in the returned list).
270
+ // Optimistic scope update before the fetch so the sidebar highlight repaints immediately. The fetch's outcome lands through the request/outcome pairing
271
+ // below - the reducer owns both the staleness decision and the failure transition - and a devices-bearing outcome selects the controller-as-device entry.
269
272
  store.dispatch({ scope: { controllerId: deviceSerial, kind: "controller" }, type: "scope:changed" });
270
273
 
271
274
  if(!getDevices) {
@@ -273,43 +276,47 @@ const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
273
276
  return;
274
277
  }
275
278
 
279
+ // Record this fetch at the store's chokepoint before awaiting, then read back the minted sequence - the store's ticket for this fetch. The newest click owns
280
+ // the pending slot, so a superseded click's outcome finds its sequence gone when it lands and drops at the reducer.
281
+ store.dispatch({ controllerId: deviceSerial, type: "devices:requested" });
282
+
283
+ const seq = store.state.devicesRequest.seq;
284
+
276
285
  try {
277
286
 
278
287
  const controller = store.state.controllers.find((c) => c.serialNumber === deviceSerial);
279
- const devices = await getDevices(controller ?? null);
288
+ const { devices, error } = await getDevices(controller ?? null);
280
289
 
290
+ // Bail if the page tore down; a torn-down store must not be dispatched against. Staleness itself is the reducer's job - it drops an outcome whose sequence no
291
+ // longer answers the pending request.
281
292
  if(signal.aborted) {
282
293
 
283
294
  return;
284
295
  }
285
296
 
286
- store.dispatch({ controllerId: deviceSerial, devices, type: "devices:loaded" });
287
-
288
- if(devices.length === 0) {
289
-
290
- // No devices returned despite a controller in scope - treat as a connection failure and surface the upstream error message via the host.
291
- const message = String(await host.request("/getErrorMessage") ?? "");
292
-
293
- if(signal.aborted) {
294
-
295
- return;
296
- }
297
+ store.dispatch({ controllerId: deviceSerial, devices, error, seq, type: "devices:loaded" });
297
298
 
298
- store.dispatch({ message, type: "connection:error" });
299
+ // Gate the follow-up on the reducer's own verdict: select the controller-as-device entry only when my outcome is the one that applied, carried no failure,
300
+ // and returned at least one device. A superseded outcome, a connection failure (the reducer moved the store to connection-error), or an empty controller each
301
+ // leaves the optimistic controller scope standing with no device-scope dispatch.
302
+ if((store.state.devicesAppliedSeq !== seq) || error.length || (devices.length === 0)) {
299
303
 
300
304
  return;
301
305
  }
302
306
 
303
307
  // Select the controller-as-device entry (the first device in the returned list).
304
308
  store.dispatch({ scope: { controllerId: deviceSerial, deviceId: devices[0].serialNumber, kind: "device" }, type: "scope:changed" });
305
- } catch {
309
+ } catch(err) {
306
310
 
311
+ // The page-teardown bail guards the reject path too. Route the rejection (an IPC failure, the contract-guard TypeError) through the same outcome channel: the
312
+ // reducer drops it if a newer click superseded this one, and otherwise clears the stale device list and moves the store to connection-error. A named Error
313
+ // reaches the user verbatim; other junk is stringified.
307
314
  if(signal.aborted) {
308
315
 
309
316
  return;
310
317
  }
311
318
 
312
- store.dispatch({ message: "Failed to fetch devices.", type: "connection:error" });
319
+ store.dispatch({ controllerId: deviceSerial, devices: [], error: errorMessage(err), seq, type: "devices:loaded" });
313
320
  }
314
321
 
315
322
  return;
@@ -33,7 +33,8 @@ import { effect } from "../store.mjs";
33
33
  *
34
34
  * @param {Object} args
35
35
  * @param {HTMLElement} args.configTable - The `#configTable` element.
36
- * @param {string | undefined} args.platform - The Homebridge plugin platform identifier (for localStorage key namespacing).
36
+ * @param {() => (string | undefined)} args.platform - A thunk returning the Homebridge plugin platform identifier (for localStorage key namespacing). Deferred as a
37
+ * thunk because the views mount before the session re-syncs, so the identifier is read inside the model:loaded effect - post-sync - rather than at mount.
37
38
  * @param {AbortSignal} args.signal - Lifecycle signal.
38
39
  * @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
39
40
  */
@@ -44,10 +45,14 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
44
45
  let mountedKey;
45
46
 
46
47
  // Per-view category expansion state, persisted via localStorage. The orchestrator writes the user's expand/collapse choices through this object so the disk
47
- // projection survives page reloads; on re-entry to a view we apply the persisted state so the user's collapse choices stay sticky across sessions.
48
- const categoryState = new FeatureOptionsCategoryState(platform);
49
-
50
- // Rebuild on model:loaded - clears any prior content and prepares for the first scope-render. The actual category shells come from the scope-render path.
48
+ // projection survives page reloads; on re-entry to a view we apply the persisted state so the user's collapse choices stay sticky across sessions. Its localStorage
49
+ // namespace is the platform identifier, which is only correct once the session has re-synced, so it is constructed inside the model:loaded effect below (reading the
50
+ // `platform` thunk post-sync) rather than at mount - the views mount before the sync resolves.
51
+ let categoryState;
52
+
53
+ // Rebuild on model:loaded - construct the category-state store from the freshly-synced platform, then clear any prior content and prepare for the first
54
+ // scope-render. The actual category shells come from the scope-render path. This effect is registered before the scope-render effect below, so on a model:loaded
55
+ // dispatch it runs first and `categoryState` is built before that effect reads it.
51
56
  effect({
52
57
 
53
58
  events: ["model:loaded"],
@@ -58,6 +63,7 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
58
63
  return;
59
64
  }
60
65
 
66
+ categoryState = new FeatureOptionsCategoryState(platform());
61
67
  configTable.textContent = "";
62
68
  cache.clear();
63
69
  mountedKey = undefined;
@@ -259,7 +265,7 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
259
265
  };
260
266
 
261
267
  // Scope-aware cache invalidation for a per-option mutation. The action's `args.id` field carries the mutation's scope marker (the persisted entry-string format
262
- // encodes scope by serial), so we discriminate by matching it against the controllers list:
268
+ // encodes scope by serial), so we distinguish it by matching against the controllers list:
263
269
  //
264
270
  // - `id` undefined - global-scope mutation. Every cached view inherits from global. Drop every entry.
265
271
  // - `id` matches a controller's serial - controller-scope mutation. Every cached device-view under this controller inherits from it. Drop entries whose key
@@ -375,7 +381,7 @@ const buildCategoryShells = ({ configTable, state }) => {
375
381
  configTable.appendChild(fragment);
376
382
  };
377
383
 
378
- // Materialize the rows for a single category. Idempotent via dataset.rowsRendered - re-opening an already-built category is a no-op for materialization.
384
+ // Materialize the rows for a single category. Guarded by dataset.rowsRendered - re-opening an already-built category is a no-op for materialization.
379
385
  const ensureRowsRendered = ({ details, state }) => {
380
386
 
381
387
  if(details.dataset.rowsRendered === "true") {
@@ -138,7 +138,7 @@ export const mountSearchView = ({ configTable, root, signal, store }) => {
138
138
  }
139
139
  };
140
140
 
141
- // Build the panel DOM and fill the `refs` record with element pointers that update functions consult later. One-time call; idempotent only insofar as it reassigns
141
+ // Build the panel DOM and fill the `refs` record with element pointers that update functions consult later. One-time call; safe to repeat only insofar as it reassigns
142
142
  // `refs` fields (the panel itself is rebuilt fresh, replacing any prior content).
143
143
  const buildPanel = ({ refs, root }) => {
144
144
 
@@ -350,7 +350,7 @@ const handleClick = ({ configTable, event, refs, store }) => {
350
350
  // On bulk expand, synchronously drive the options view's capture-phase `toggle` handler so each category's rows materialize in the SAME layout pass as the open,
351
351
  // rather than the open reflowing first and the async native `toggle` task materializing in a second pass. Collapsing the opens and materializations into one
352
352
  // settled height change keeps the host's iframe-resize ResizeObserver from cascading ("ResizeObserver loop completed with undelivered notifications"). Row
353
- // materialization is idempotent, so the native async toggle that follows is a no-op. Collapse needs no synthetic toggle - hiding rows is already one reflow.
353
+ // materialization only runs once, so the native async toggle that follows is a no-op. Collapse needs no synthetic toggle - hiding rows is already one reflow.
354
354
  if(shouldExpand) {
355
355
 
356
356
  details.dispatchEvent(new Event("toggle"));