homebridge-plugin-utils 2.1.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 (38) hide show
  1. package/build/tsconfig.json +15 -0
  2. package/dist/cli/index.d.ts +20 -3
  3. package/dist/cli/index.js +77 -7
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/docChrome.d.ts +4 -1
  6. package/dist/docChrome.js +3 -0
  7. package/dist/docChrome.js.map +1 -1
  8. package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
  9. package/dist/ffmpeg/fmp4-builders.js +163 -0
  10. package/dist/ffmpeg/fmp4-builders.js.map +1 -0
  11. package/dist/ffmpeg/index.d.ts +1 -0
  12. package/dist/ffmpeg/index.js +1 -0
  13. package/dist/ffmpeg/index.js.map +1 -1
  14. package/dist/ffmpeg/options.d.ts +17 -0
  15. package/dist/ffmpeg/options.js +47 -22
  16. package/dist/ffmpeg/options.js.map +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/timer-registry.d.ts +100 -0
  21. package/dist/timer-registry.js +184 -0
  22. package/dist/timer-registry.js.map +1 -0
  23. package/dist/ui/webUi-featureOptions/state.mjs +68 -11
  24. package/dist/ui/webUi-featureOptions/utils.mjs +16 -5
  25. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +27 -10
  26. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
  27. package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
  28. package/dist/ui/webUi-featureOptions/views/nav.mjs +31 -38
  29. package/dist/ui/webUi-featureOptions/views/options.mjs +11 -5
  30. package/dist/ui/webUi-featureOptions.mjs +66 -43
  31. package/dist/ui/webUi.mjs +5 -0
  32. package/dist/util.d.ts +29 -4
  33. package/dist/util.js +34 -0
  34. package/dist/util.js.map +1 -1
  35. package/dist/webui-loader.d.ts +80 -0
  36. package/dist/webui-loader.js +373 -0
  37. package/dist/webui-loader.js.map +1 -0
  38. package/package.json +4 -4
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timer-registry.js","sourceRoot":"","sources":["../src/timer-registry.ts"],"names":[],"mappings":"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"}
@@ -16,7 +16,7 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
16
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
@@ -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,9 +120,16 @@ 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.
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.
119
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
@@ -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.
@@ -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 with the user-facing message that arrived alongside the
308
- // device-list response; 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: {
@@ -200,14 +200,25 @@ export function showToast(message, variant = "alert-success") {
200
200
  }
201
201
 
202
202
  /**
203
- * Surface an arbitrary thrown value as an error toast. The webUI's extension points - caller-supplied first-run hooks, plugin device fetchers, the connection-error
204
- * retry callback - can reject with any shape (an Error, a string, a plain object, a primitive), so the message is extracted defensively: `err?.message` when the
205
- * value carries one, a string coercion of the whole value otherwise. This is the single normalization every user-facing catch across the webUI routes through, so
206
- * the toast text stays useful regardless of what bubbled out.
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.
207
218
  *
208
219
  * @param {*} err - The thrown value to surface.
209
220
  */
210
221
  export function toastError(err) {
211
222
 
212
- homebridge.toast.error(err?.message ?? String(err), "Error");
223
+ homebridge.toast.error(errorMessage(err), "Error");
213
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
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,11 +28,12 @@ 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 DeviceListResult, then dispatches a
32
- * `devices:loaded` action followed by either a scope change (devices present) or a `connection:error` (the result carried a failure message). A per-mount fetch
33
- * generation guards the last-resolved-wins race: the newest click owns the store, so a superseded controller click discards its result on both settlement paths
34
- * (resolve and reject) rather than overwriting the newer click's rendered state. The handler wraps its fetch in a try/catch so a rejected fetch surfaces as a
35
- * `connection:error` action rather than an unhandled rejection; the view layer never silently swallows a 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.
36
37
  *
37
38
  * @param {Object} args
38
39
  * @param {((controller: import("../state.mjs").Controller | null) =>
@@ -97,14 +98,9 @@ export const mountNavView = ({ getDevices, labelControllers, labelDevices, rootC
97
98
  store
98
99
  });
99
100
 
100
- // Per-mount fetch generation for the controller-click handler. Held as a mutable reference (not a plain number) because handleNavClick is module-scope: a number
101
- // would be copied by value into the call, defeating the guard. The closure-held object mirrors the connection-error view's retryAbort per-mount state. Each
102
- // controller click captures the incremented generation before its fetch; a continuation whose generation no longer matches has been superseded by a newer click and
103
- // discards its result.
104
- const clickFetch = { generation: 0 };
105
-
106
- // Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change.
107
- const onClick = (event) => handleNavClick({ clickFetch, event, getDevices, 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 });
108
104
 
109
105
  rootControllers.addEventListener("click", onClick, { signal });
110
106
  rootDevices.addEventListener("click", onClick, { signal });
@@ -246,7 +242,7 @@ const applyDevicesHighlight = (root, scope) => {
246
242
 
247
243
  // Handle a click on any nav link. Resolves the click target's `data-navigation` and dispatches the corresponding scope-change. Controller clicks additionally
248
244
  // fetch the new controller's DeviceListResult via the caller-supplied `getDevices` callback.
249
- const handleNavClick = async ({ clickFetch, event, getDevices, signal, store }) => {
245
+ const handleNavClick = async ({ event, getDevices, signal, store }) => {
250
246
 
251
247
  const navLink = event.target.closest(".nav-link[data-navigation]");
252
248
 
@@ -271,8 +267,8 @@ const handleNavClick = async ({ clickFetch, event, getDevices, signal, store })
271
267
 
272
268
  case "controller": {
273
269
 
274
- // Optimistic scope update before the fetch so the sidebar highlight repaints immediately. If the result carries a failure message we transition to
275
- // connection:error; if it carries devices we 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.
276
272
  store.dispatch({ scope: { controllerId: deviceSerial, kind: "controller" }, type: "scope:changed" });
277
273
 
278
274
  if(!getDevices) {
@@ -280,35 +276,31 @@ const handleNavClick = async ({ clickFetch, event, getDevices, signal, store })
280
276
  return;
281
277
  }
282
278
 
283
- // Capture this fetch's generation before awaiting. The newest click owns the store, so a continuation whose generation no longer matches has been superseded by
284
- // a later click and discards its result on both settlement paths below.
285
- const generation = ++clickFetch.generation;
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;
286
284
 
287
285
  try {
288
286
 
289
287
  const controller = store.state.controllers.find((c) => c.serialNumber === deviceSerial);
290
288
  const { devices, error } = await getDevices(controller ?? null);
291
289
 
292
- // Bail if the page tore down or a newer click superseded this one; a stale continuation must not overwrite the newer click's rendered state.
293
- if(signal.aborted || (generation !== clickFetch.generation)) {
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.
292
+ if(signal.aborted) {
294
293
 
295
294
  return;
296
295
  }
297
296
 
298
- store.dispatch({ controllerId: deviceSerial, devices, type: "devices:loaded" });
299
-
300
- if(error.length) {
301
-
302
- // The result carried a connection-failure message - hand the header to the connection-error view. The message arrived with the device-list response, so no
303
- // separate request is made.
304
- store.dispatch({ message: error, type: "connection:error" });
305
-
306
- return;
307
- }
297
+ store.dispatch({ controllerId: deviceSerial, devices, error, seq, type: "devices:loaded" });
308
298
 
309
- if(devices.length === 0) {
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)) {
310
303
 
311
- // A legitimately empty controller: the optimistic controller scope stands over the empty list, with no device-scope dispatch.
312
304
  return;
313
305
  }
314
306
 
@@ -316,14 +308,15 @@ const handleNavClick = async ({ clickFetch, event, getDevices, signal, store })
316
308
  store.dispatch({ scope: { controllerId: deviceSerial, deviceId: devices[0].serialNumber, kind: "device" }, type: "scope:changed" });
317
309
  } catch(err) {
318
310
 
319
- // The same staleness bail guards the reject path: a superseded fetch that rejects (an IPC failure, the contract-guard TypeError) must not dispatch a stale
320
- // connection:error over the newer click's state. A named Error reaches the user verbatim; non-Error junk falls back to the generic sentence.
321
- if(signal.aborted || (generation !== clickFetch.generation)) {
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.
314
+ if(signal.aborted) {
322
315
 
323
316
  return;
324
317
  }
325
318
 
326
- store.dispatch({ message: (err instanceof Error) ? err.message : "Failed to fetch devices.", type: "connection:error" });
319
+ store.dispatch({ controllerId: deviceSerial, devices: [], error: errorMessage(err), seq, type: "devices:loaded" });
327
320
  }
328
321
 
329
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;