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.
- package/build/tsconfig.json +15 -0
- package/dist/cli/index.d.ts +20 -3
- package/dist/cli/index.js +77 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/docChrome.d.ts +4 -1
- package/dist/docChrome.js +3 -0
- package/dist/docChrome.js.map +1 -1
- package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
- package/dist/ffmpeg/fmp4-builders.js +163 -0
- package/dist/ffmpeg/fmp4-builders.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +1 -0
- package/dist/ffmpeg/index.js +1 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/options.d.ts +17 -0
- package/dist/ffmpeg/options.js +47 -22
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/timer-registry.d.ts +100 -0
- package/dist/timer-registry.js +184 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/ui/webUi-featureOptions/state.mjs +68 -11
- package/dist/ui/webUi-featureOptions/utils.mjs +16 -5
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +27 -10
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
- package/dist/ui/webUi-featureOptions/views/nav.mjs +31 -38
- package/dist/ui/webUi-featureOptions/views/options.mjs +11 -5
- package/dist/ui/webUi-featureOptions.mjs +66 -43
- package/dist/ui/webUi.mjs +5 -0
- package/dist/util.d.ts +29 -4
- package/dist/util.js +34 -0
- package/dist/util.js.map +1 -1
- package/dist/webui-loader.d.ts +80 -0
- package/dist/webui-loader.js +373 -0
- package/dist/webui-loader.js.map +1 -0
- 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,
|
|
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:
|
|
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` -
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
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
|
-
//
|
|
308
|
-
//
|
|
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
|
-
*
|
|
204
|
-
* retry callback - can reject with any shape (an Error, a string, a plain object, a primitive), so the message is extracted
|
|
205
|
-
* value carries one, a string coercion of the whole value otherwise. This is the single
|
|
206
|
-
*
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
72
|
-
// supplied retry signal.
|
|
73
|
-
|
|
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
|
-
|
|
94
|
+
headline,
|
|
78
95
|
createElement("br"),
|
|
79
|
-
|
|
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 `
|
|
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: [ "
|
|
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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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 ({
|
|
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.
|
|
275
|
-
//
|
|
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
|
-
//
|
|
284
|
-
// a
|
|
285
|
-
|
|
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
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
//
|
|
321
|
-
|
|
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({
|
|
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 -
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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;
|