homebridge-plugin-utils 1.35.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +94 -45
- package/dist/backpressure.js +229 -93
- package/dist/backpressure.js.map +1 -1
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -262
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +49 -2
- package/dist/ffmpeg/fmp4.js +47 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +142 -96
- package/dist/ffmpeg/process.js +406 -278
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -567
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -251
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -2
- package/dist/ffmpeg/settings.js +20 -5
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -150
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +602 -45
- package/dist/util.js +783 -78
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/selectors.mjs: Memoized pure derivations over the feature options state.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { buildConfigIndex, expandOption, isDependencyMet, isValueOption, resolveScope } from "../featureOptions.js";
|
|
8
|
+
import { memoize } from "./store.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Memoized pure derivations over the feature options state.
|
|
12
|
+
*
|
|
13
|
+
* Every selector here reads from {@link FeatureOptionsState} (the shape defined in `webUi-featureOptions/state.mjs`) and returns a derived value. Selectors are
|
|
14
|
+
* the read side of the unidirectional data flow - views and effects consume them, dispatches do not. Each selector is memoized via {@link memoize} on its specific
|
|
15
|
+
* dependency slices, so a state transition that does not touch a selector's inputs returns a cached result in O(1).
|
|
16
|
+
*
|
|
17
|
+
* Composition: selectors that build on other selectors call them through their memoized exports. {@link projection} calls {@link configIndex} and
|
|
18
|
+
* {@link selectedDevice}; each of those is independently cached. A dispatch that only changes `state.filter` re-runs `projection` (cache miss) but the inner
|
|
19
|
+
* `configIndex` and `selectedDevice` calls hit their caches and return without recomputation.
|
|
20
|
+
*
|
|
21
|
+
* Reference-equality everywhere: the reducer's structural-sharing contract guarantees that unchanged slices retain their reference across dispatches. Selectors
|
|
22
|
+
* compare slice references via `===`, which is the right grain for typical state mutations - a `scope:changed` dispatch produces a new `state.scope` reference but
|
|
23
|
+
* leaves `state.catalog` and `state.configuredOptions` unchanged, so any selector depending only on the latter pair hits its cache.
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the controller serial from the scope discriminator, or null when no controller is in context. Pure helper - one-line discriminant read, not memoized.
|
|
30
|
+
*
|
|
31
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
32
|
+
* @returns {string | null} The controller serial when the scope is controller-based, null otherwise.
|
|
33
|
+
*/
|
|
34
|
+
export const selectedControllerId = (state) => {
|
|
35
|
+
|
|
36
|
+
switch(state.scope.kind) {
|
|
37
|
+
|
|
38
|
+
case "controller":
|
|
39
|
+
|
|
40
|
+
return state.scope.controllerId;
|
|
41
|
+
|
|
42
|
+
case "device":
|
|
43
|
+
|
|
44
|
+
return state.scope.controllerId;
|
|
45
|
+
|
|
46
|
+
default:
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract the device serial from the scope discriminator, or null when the scope is global or controller-only. Pure helper - one-line discriminant read, not
|
|
54
|
+
* memoized.
|
|
55
|
+
*
|
|
56
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
57
|
+
* @returns {string | null} The device serial when the scope is device-based, null otherwise.
|
|
58
|
+
*/
|
|
59
|
+
export const selectedDeviceId = (state) => {
|
|
60
|
+
|
|
61
|
+
return (state.scope.kind === "device") ? state.scope.deviceId : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map a {@link Scope} to a string key that uniquely identifies the view. Pure derivation from the discriminated union - every Scope variant maps to a distinct
|
|
66
|
+
* key and no two variants collide.
|
|
67
|
+
*
|
|
68
|
+
* Used as the SSOT identifier for "which view is this" wherever a view's identity matters across mutations or navigations: the in-memory DOM cache keys its
|
|
69
|
+
* entries by this, and the category-state localStorage projection uses the same key as its context identifier. Sharing one identifier across the two consumers
|
|
70
|
+
* means a navigation and a localStorage lookup observe the same notion of "view," and any future code that needs to address a view by id picks up the same
|
|
71
|
+
* convention without inventing a parallel scheme.
|
|
72
|
+
*
|
|
73
|
+
* Format:
|
|
74
|
+
*
|
|
75
|
+
* - `global` - the global-scope view, single canonical key.
|
|
76
|
+
* - `controller:<serial>` - a controller's transient between-click view. Distinct per controller; never collides with the global key.
|
|
77
|
+
* - `device:<controllerSerial>/<deviceSerial>` - a device view under a controller (or under no controller in device-only mode, where the slot is empty). The
|
|
78
|
+
* compound key encodes the full inheritance lineage so a controller-scope cache invalidation can match every device under that controller by string prefix
|
|
79
|
+
* in O(N) over the cache without consulting the controller/devices lists.
|
|
80
|
+
*
|
|
81
|
+
* Delimiter contract: the device-key format relies on serial values not containing the `"/"` character, which matches the MAC-derived hex format Homebridge uses
|
|
82
|
+
* for device and controller serials. A serial containing `"/"` would let the prefix match in {@link mountOptionsView}'s scope-aware cache invalidation over-match
|
|
83
|
+
* unrelated entries. If that assumption ever needs to weaken (e.g., a plugin starts surfacing user-chosen serials), encode the components with
|
|
84
|
+
* `encodeURIComponent` at this seam before composing the key.
|
|
85
|
+
*
|
|
86
|
+
* @param {import("./state.mjs").Scope} scope - The scope discriminator.
|
|
87
|
+
* @returns {string} A stable string key identifying the view.
|
|
88
|
+
*/
|
|
89
|
+
export const scopeCacheKey = (scope) => {
|
|
90
|
+
|
|
91
|
+
switch(scope.kind) {
|
|
92
|
+
|
|
93
|
+
case "global":
|
|
94
|
+
|
|
95
|
+
return "global";
|
|
96
|
+
|
|
97
|
+
case "controller":
|
|
98
|
+
|
|
99
|
+
return "controller:" + scope.controllerId;
|
|
100
|
+
|
|
101
|
+
case "device":
|
|
102
|
+
|
|
103
|
+
return "device:" + (scope.controllerId ?? "") + "/" + scope.deviceId;
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
|
|
107
|
+
// Exhaustive switch over the Scope DU - this branch is unreachable as long as the DU stays in sync with the cases above. A future variant addition surfaces
|
|
108
|
+
// here as a runtime throw rather than a silent fallthrough that produces a colliding key.
|
|
109
|
+
throw new Error("scopeCacheKey: unknown scope kind.");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the O(1) lookup index from the configured-options array. Memoized on `(catalog, configuredOptions)` so any dispatch that does not touch the configured
|
|
115
|
+
* options returns the cached index without rebuilding.
|
|
116
|
+
*
|
|
117
|
+
* Consumed by every other selector that needs to resolve options through the scope hierarchy (projection, rendering factories, effects) - sharing one cached index
|
|
118
|
+
* across them means the parse-and-build work happens at most once per configured-options mutation.
|
|
119
|
+
*
|
|
120
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
121
|
+
* @returns {import("../featureOptions.js").ConfigIndex} The lookup index.
|
|
122
|
+
*/
|
|
123
|
+
export const configIndex = memoize({
|
|
124
|
+
|
|
125
|
+
compute: (state) => buildConfigIndex(state.catalog, state.configuredOptions),
|
|
126
|
+
slices: [ (s) => s.catalog, (s) => s.configuredOptions ]
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the currently-selected device by walking the devices list for the scope's deviceId. Memoized on `(scope, devices)` so any dispatch that does not touch
|
|
131
|
+
* the selection or the devices list returns the cached result.
|
|
132
|
+
*
|
|
133
|
+
* Returns `undefined` for the global view (no device in scope) and for a scope.kind of "controller" (the controller-level view has no concrete device until the
|
|
134
|
+
* user drills in). Consumers that need the device for validator calls pass `undefined` through directly - the validator signatures expect a possibly-undefined
|
|
135
|
+
* device for exactly this case.
|
|
136
|
+
*
|
|
137
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
138
|
+
* @returns {import("./state.mjs").Device | undefined} The selected device, or undefined.
|
|
139
|
+
*/
|
|
140
|
+
export const selectedDevice = memoize({
|
|
141
|
+
|
|
142
|
+
compute: (state) => {
|
|
143
|
+
|
|
144
|
+
if(state.scope.kind !== "device") {
|
|
145
|
+
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return state.devices.find((d) => d.serialNumber === state.scope.deviceId);
|
|
150
|
+
},
|
|
151
|
+
slices: [ (s) => s.scope, (s) => s.devices ]
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the currently-selected controller by walking the controllers list for the scope's controllerId. Memoized on `(scope, controllers)`.
|
|
156
|
+
*
|
|
157
|
+
* Returns `null` for the global view (no controller in scope), the device-only-mode device view (controllerId is null), or when the controller is not found.
|
|
158
|
+
* Consumers display the controller's name in headers / breadcrumbs and skip the display entirely on null.
|
|
159
|
+
*
|
|
160
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
161
|
+
* @returns {import("./state.mjs").Controller | null} The selected controller, or null.
|
|
162
|
+
*/
|
|
163
|
+
export const selectedController = memoize({
|
|
164
|
+
|
|
165
|
+
compute: (state) => {
|
|
166
|
+
|
|
167
|
+
const controllerId = selectedControllerId(state);
|
|
168
|
+
|
|
169
|
+
if(controllerId === null) {
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return state.controllers.find((c) => c.serialNumber === controllerId) ?? null;
|
|
175
|
+
},
|
|
176
|
+
slices: [ (s) => s.scope, (s) => s.controllers ]
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {Object} ProjectionEntry
|
|
181
|
+
* @property {string} description - The option's display description.
|
|
182
|
+
* @property {boolean} enabled - The resolved enabled state at the highest-precedence scope where the option was found (or the catalog default at scope "none").
|
|
183
|
+
* @property {string} expandedName - The canonical `category.option` identifier.
|
|
184
|
+
* @property {boolean} isGrouped - The option declares a `group` in the catalog (subordinate to a parent option).
|
|
185
|
+
* @property {boolean} isModified - The option has a configured entry at any scope (not just the default).
|
|
186
|
+
* @property {string} name - The option's catalog name (without the category prefix).
|
|
187
|
+
* @property {import("../featureOptions.js").FeatureOptionEntry} option - The raw catalog entry for the option.
|
|
188
|
+
* @property {boolean} requiresParentBadge - The "requires parent" badge applies: option is visible, grouped, and its parent is currently disabled.
|
|
189
|
+
* @property {import("../featureOptions.js").OptionScope} scope - The scope at which the option resolved ("device" / "controller" / "global" / "none").
|
|
190
|
+
* @property {string | undefined} value - The resolved value for value-centric options when enabled; undefined for booleans and disabled options.
|
|
191
|
+
* @property {boolean} visible - The option's row should be displayed under the current search query, filter mode, and dependency state.
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @typedef {Object} ProjectionCategory
|
|
196
|
+
* @property {import("../featureOptions.js").FeatureCategoryEntry} category - The raw catalog entry for the category.
|
|
197
|
+
* @property {string} description - The category's display description.
|
|
198
|
+
* @property {readonly ProjectionEntry[]} entries - The active options for this category, in catalog order.
|
|
199
|
+
* @property {boolean} hasVisible - At least one entry has `visible === true`.
|
|
200
|
+
* @property {string} name - The category's name.
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @typedef {Object} ProjectionCounts
|
|
205
|
+
* @property {number} grouped - Active options that declare a `group` (subordinate to a parent).
|
|
206
|
+
* @property {number} modified - Active options with an explicit configured entry at any scope.
|
|
207
|
+
* @property {number} total - Active options across every active (validator-passed) category, regardless of per-entry visibility.
|
|
208
|
+
* @property {number} visible - Active options currently visible under the search query, filter mode, and dependency state.
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @typedef {Object} Projection
|
|
213
|
+
* @property {readonly ProjectionCategory[]} categories - Active categories in catalog order. Categories with zero active options are omitted.
|
|
214
|
+
* @property {ProjectionCounts} counts - Aggregate counts across the active option set.
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The view projection. One pass over the active option set produces every downstream display decision: status-bar counts, per-category visibility, per-row
|
|
219
|
+
* visibility, per-row dependency-badge state, and per-row resolved value for value-centric options. Memoized on `(catalog, configuredOptions, scope, filter,
|
|
220
|
+
* devices)` so any dispatch that does not touch those slices returns the cached projection.
|
|
221
|
+
*
|
|
222
|
+
* Visibility rules (the three-way cascade below is authoritative for what a row shows):
|
|
223
|
+
*
|
|
224
|
+
* - The `modified` filter excludes unmodified options unconditionally.
|
|
225
|
+
* - A non-empty search query excludes options whose description does not contain the query (case-insensitive).
|
|
226
|
+
* - When either search or modified filter is active, dependency-hiding is SUPPRESSED - grouped options with disabled parents stay visible (with a "requires
|
|
227
|
+
* parent" badge) instead of disappearing. The rationale: a user who searched explicitly wants to see matches; hiding a match because its parent is off would
|
|
228
|
+
* be more confusing than the badge.
|
|
229
|
+
* - When neither search nor filter is active, grouped options with disabled parents are HIDDEN entirely. The dependency-hide is the natural state.
|
|
230
|
+
*
|
|
231
|
+
* `requiresParentBadge` collapses the combined predicate `visible && isGrouped && !dependencyMet` into one boolean so rendering code does not have to reconstruct
|
|
232
|
+
* the rule from the raw fields.
|
|
233
|
+
*
|
|
234
|
+
* @param {import("./state.mjs").FeatureOptionsState} state - The current state.
|
|
235
|
+
* @returns {Projection} The computed projection.
|
|
236
|
+
*/
|
|
237
|
+
export const projection = memoize({
|
|
238
|
+
|
|
239
|
+
compute: (state) => computeProjection(state),
|
|
240
|
+
slices: [ (s) => s.catalog, (s) => s.configuredOptions, (s) => s.scope, (s) => s.filter, (s) => s.devices ]
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// The projection's compute path. Walks the catalog once, applies validators, resolves each option through the scope hierarchy, computes per-entry flags and the
|
|
244
|
+
// overall counts. Pulled out of the memoize call site for readability - the function body is too long to inline in a property value.
|
|
245
|
+
const computeProjection = (state) => {
|
|
246
|
+
|
|
247
|
+
const { catalog, filter } = state;
|
|
248
|
+
const idx = configIndex(state);
|
|
249
|
+
const device = selectedDevice(state);
|
|
250
|
+
const controllerId = selectedControllerId(state) ?? undefined;
|
|
251
|
+
const deviceId = selectedDeviceId(state) ?? undefined;
|
|
252
|
+
const query = filter.query.toLowerCase();
|
|
253
|
+
const filterActive = (query.length > 0) || (filter.mode === "modified");
|
|
254
|
+
|
|
255
|
+
const categories = [];
|
|
256
|
+
const counts = { grouped: 0, modified: 0, total: 0, visible: 0 };
|
|
257
|
+
|
|
258
|
+
for(const category of catalog.categories) {
|
|
259
|
+
|
|
260
|
+
if(!catalog.validators.validOptionCategory(device, category)) {
|
|
261
|
+
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const entries = [];
|
|
266
|
+
let categoryHasVisible = false;
|
|
267
|
+
|
|
268
|
+
for(const option of (catalog.options[category.name] ?? [])) {
|
|
269
|
+
|
|
270
|
+
if(!catalog.validators.validOption(device, option)) {
|
|
271
|
+
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const expandedName = expandOption(category, option);
|
|
276
|
+
const resolved = resolveScope({ catalog, configIndex: idx, controller: controllerId, device: deviceId, option: expandedName });
|
|
277
|
+
const optionIsGrouped = option.group !== undefined;
|
|
278
|
+
const optionIsModified = resolved.scope !== "none";
|
|
279
|
+
const optionDependencyMet = isDependencyMet({ catalog, configIndex: idx, controller: controllerId, device: deviceId, option: expandedName });
|
|
280
|
+
|
|
281
|
+
// Visibility cascade: modified filter, search query, then dependency-hide (only when neither filter nor search is active).
|
|
282
|
+
let visible = true;
|
|
283
|
+
|
|
284
|
+
if((filter.mode === "modified") && !optionIsModified) {
|
|
285
|
+
|
|
286
|
+
visible = false;
|
|
287
|
+
} else if((query.length > 0) && !option.description.toLowerCase().includes(query)) {
|
|
288
|
+
|
|
289
|
+
visible = false;
|
|
290
|
+
} else if(!filterActive) {
|
|
291
|
+
|
|
292
|
+
visible = optionDependencyMet;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Resolve the displayable value for value-centric options. Mirrors the FeatureOptions.value() semantics: explicit configured value wins, otherwise the
|
|
296
|
+
// catalog-declared default when the option is enabled at no explicit scope ("none"), otherwise undefined (enabled at scope but no value provided).
|
|
297
|
+
let value;
|
|
298
|
+
|
|
299
|
+
if(isValueOption(catalog, expandedName) && resolved.enabled) {
|
|
300
|
+
|
|
301
|
+
if(resolved.optionValue !== undefined) {
|
|
302
|
+
|
|
303
|
+
value = resolved.optionValue;
|
|
304
|
+
} else if(resolved.scope === "none") {
|
|
305
|
+
|
|
306
|
+
value = catalog.valueOptions[expandedName.toLowerCase()]?.toString();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
counts.total++;
|
|
311
|
+
|
|
312
|
+
if(optionIsGrouped) {
|
|
313
|
+
|
|
314
|
+
counts.grouped++;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if(optionIsModified) {
|
|
318
|
+
|
|
319
|
+
counts.modified++;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if(visible) {
|
|
323
|
+
|
|
324
|
+
counts.visible++;
|
|
325
|
+
categoryHasVisible = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
entries.push({
|
|
329
|
+
|
|
330
|
+
description: option.description,
|
|
331
|
+
enabled: resolved.enabled,
|
|
332
|
+
expandedName,
|
|
333
|
+
isGrouped: optionIsGrouped,
|
|
334
|
+
isModified: optionIsModified,
|
|
335
|
+
name: option.name,
|
|
336
|
+
option,
|
|
337
|
+
requiresParentBadge: visible && optionIsGrouped && !optionDependencyMet,
|
|
338
|
+
scope: resolved.scope,
|
|
339
|
+
value,
|
|
340
|
+
visible
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if(entries.length === 0) {
|
|
345
|
+
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
categories.push({
|
|
350
|
+
|
|
351
|
+
category,
|
|
352
|
+
description: category.description,
|
|
353
|
+
entries,
|
|
354
|
+
hasVisible: categoryHasVisible,
|
|
355
|
+
name: category.name
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { categories, counts };
|
|
360
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/state.mjs: State shape, action vocabulary, and reducer for the feature options webUI.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureOptions.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* State shape, action vocabulary, and reducer for the feature options webUI.
|
|
11
|
+
*
|
|
12
|
+
* This module is the SSOT for what state the UI carries and how that state transitions. Every dispatch lands here; every component reads from {@link FeatureOptionsState}
|
|
13
|
+
* and derives its view via selectors. Discriminated unions encode the variant types the UI moves through, each listed below:
|
|
14
|
+
*
|
|
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
|
|
17
|
+
* kind via predicates.
|
|
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).
|
|
20
|
+
* - {@link Catalog} - `CatalogIndex` (from featureOptions.ts) extended with plugin-provided validator callbacks. Bundled as one value because both the index and
|
|
21
|
+
* the validators are plugin-provided immutable config moving together; splitting them would force every consumer that needs both to take two parameters.
|
|
22
|
+
*
|
|
23
|
+
* The action vocabulary names past-tense domain events. Each action below corresponds to a {@link reducer} case and to at least one effect or view subscriber.
|
|
24
|
+
* Names use a `domain:event` shape so they group naturally and read as natural language at dispatch sites.
|
|
25
|
+
*
|
|
26
|
+
* - `model:loaded` - first load: catalog, configuredOptions, controllers, mode are populated; status transitions to ready.
|
|
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.
|
|
29
|
+
* - `scope:changed` - selection pointer moved (global / controller / device).
|
|
30
|
+
* - `option:set` - single option enabled/disabled (with optional value) at some scope.
|
|
31
|
+
* - `option:cleared` - single option removed at some scope.
|
|
32
|
+
* - `options:reset` - every configured option dropped (reset to defaults).
|
|
33
|
+
* - `model:reverted` - configuredOptions restored to the at-show() snapshot.
|
|
34
|
+
* - `filter:changed` - search query and/or filter mode updated.
|
|
35
|
+
* - `persist:started` - persist call entering flight; status becomes persisting.
|
|
36
|
+
* - `persist:succeeded` - persist call landed on disk; anchor updated, status returns to ready.
|
|
37
|
+
* - `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.
|
|
39
|
+
*
|
|
40
|
+
* The reducer is pure: `(state, action) => state`. Unchanged slices retain their reference across dispatches (structural sharing), so memoized selectors that
|
|
41
|
+
* 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
|
|
42
|
+
* production where they manifest as missing UI updates.
|
|
43
|
+
*
|
|
44
|
+
* @module
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} Controller
|
|
49
|
+
* @property {string} address - The network address of the controller.
|
|
50
|
+
* @property {string} name - The display name of the controller.
|
|
51
|
+
* @property {string} serialNumber - The unique serial number of the controller.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} Device
|
|
56
|
+
* @property {string} firmwareRevision - The firmware version of the device.
|
|
57
|
+
* @property {string} manufacturer - The manufacturer of the device.
|
|
58
|
+
* @property {string} model - The model identifier of the device.
|
|
59
|
+
* @property {string} name - The display name of the device.
|
|
60
|
+
* @property {string} serialNumber - The unique serial number of the device.
|
|
61
|
+
* @property {string} [sidebarGroup] - Optional grouping identifier for sidebar organization.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {Object} Validators
|
|
66
|
+
* @property {(device: Device) => boolean} isController - Predicate for "is this device a controller-as-device" (drives controller-vs-device scope discrimination).
|
|
67
|
+
* @property {(device: Device | undefined, option: Object) => boolean} validOption - Predicate for "should this option render for this device."
|
|
68
|
+
* @property {(device: Device | undefined, category: Object) => boolean} validOptionCategory - Predicate for "should this category render for this device."
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Catalog - The plugin-provided immutable configuration bundle. `CatalogIndex` from featureOptions.ts carries the catalog data and its derived indices; the
|
|
73
|
+
* `validators` field adds the webUI-specific predicates plugins supply for device-aware visibility. Both halves are set once at {@link model:loaded} and never
|
|
74
|
+
* change during a session; consumers can rely on reference stability for memoization.
|
|
75
|
+
*
|
|
76
|
+
* @typedef {Object} Catalog
|
|
77
|
+
* @property {readonly import("../featureOptions.js").FeatureCategoryEntry[]} categories
|
|
78
|
+
* @property {Readonly<Record<string, boolean>>} defaults
|
|
79
|
+
* @property {Readonly<Record<string, string>>} groupParents
|
|
80
|
+
* @property {Readonly<Record<string, readonly string[]>>} groups
|
|
81
|
+
* @property {Readonly<Record<string, readonly import("../featureOptions.js").FeatureOptionEntry[]>>} options
|
|
82
|
+
* @property {Readonly<Record<string, (value: string) => string>>} renderers
|
|
83
|
+
* @property {readonly string[]} sortedValueOptionNames
|
|
84
|
+
* @property {Validators} validators
|
|
85
|
+
* @property {Readonly<Record<string, number | string | undefined>>} valueOptions
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Scope - The selection pointer through the global / controller / device hierarchy. Discriminated by `kind` so each variant carries exactly its required data
|
|
90
|
+
* and invalid combinations (a "device" view without a deviceId, a "controller" view without a controllerId) are unrepresentable.
|
|
91
|
+
*
|
|
92
|
+
* The `device` variant carries `controllerId: string | null` because two device-view shapes exist: a device under a controller (controllerId is the parent
|
|
93
|
+
* controller's serial) and a device in device-only mode (controllerId is null because there is no controller). Splitting these into two further variants would
|
|
94
|
+
* over-fragment the type for a distinction that no consumer cares about - both render the same view.
|
|
95
|
+
*
|
|
96
|
+
* @typedef {{kind: "global"} | {kind: "controller", controllerId: string} | {kind: "device", controllerId: string | null, deviceId: string}} Scope
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* LifecycleStatus - The page-state pointer. Discriminated because the variants carry different per-state payloads. Drop a status variant when it stops being a
|
|
101
|
+
* named UI state; add one when a new named state surfaces.
|
|
102
|
+
*
|
|
103
|
+
* @typedef {{kind: "loading"} | {kind: "ready"} | {kind: "persisting", snapshot: readonly string[]} | {kind: "persist-error", error: Error}
|
|
104
|
+
* | {kind: "connection-error", message: string}} LifecycleStatus
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* FeatureOptionsState - The complete state shape. Every field has a defined purpose; every consumer reads from here. No view, effect, or component holds parallel
|
|
109
|
+
* state that could drift from this.
|
|
110
|
+
*
|
|
111
|
+
* @typedef {Object} FeatureOptionsState
|
|
112
|
+
* @property {Catalog} catalog - Plugin-provided immutable configuration: catalog index + validators.
|
|
113
|
+
* @property {readonly string[]} configuredOptions - The canonical user-state array. Mutations replace it via the pure transforms from featureOptions.ts.
|
|
114
|
+
* @property {readonly Controller[]} controllers - Controllers list (empty in device-only mode or before resolution).
|
|
115
|
+
* @property {readonly Device[]} devices - Devices list for the active controller (or the cached-accessories list in device-only mode).
|
|
116
|
+
* @property {string | null} devicesControllerId - Serial of the controller whose `devices` are loaded, or null (device-only / none yet). Preserves the
|
|
117
|
+
* device-to-controller association that `scope` drops once the selection goes global, so a loaded device's parent controller stays resolvable after the
|
|
118
|
+
* 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
|
|
120
|
+
* is valid and meaningful).
|
|
121
|
+
* @property {readonly string[]} initialOptions - The at-show() snapshot for "Revert to Saved." Stable across the session except when re-show() loads an option
|
|
122
|
+
* set that is not set-equal to the prior snapshot, in which case the snapshot is replaced.
|
|
123
|
+
* @property {"controller-based" | "device-only"} mode - Operating mode. Set once at model:loaded based on whether the plugin provided `getControllers`.
|
|
124
|
+
* @property {readonly string[]} persistedAnchor - The last-known-on-disk state. Updated on every successful persist; restored to configuredOptions on a final
|
|
125
|
+
* persist failure (memory then matches disk).
|
|
126
|
+
* @property {Scope} scope - Selection pointer (DU).
|
|
127
|
+
* @property {LifecycleStatus} status - Page-state pointer (DU).
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
// The placeholder catalog used during the "loading" status, before {@link model:loaded} has fired with the real one. Built from empty inputs so every selector
|
|
131
|
+
// works against it without null guards; selectors that iterate categories or options produce empty results, which matches the "nothing to render yet" semantics.
|
|
132
|
+
// validOption and validOptionCategory default to permissive (return true) so any speculative iteration during loading does not accidentally hide a category
|
|
133
|
+
// or option; isController defaults to false since it plays no role in visibility.
|
|
134
|
+
const EMPTY_CATALOG = {
|
|
135
|
+
|
|
136
|
+
...buildCatalogIndex([], {}),
|
|
137
|
+
|
|
138
|
+
validators: {
|
|
139
|
+
|
|
140
|
+
isController: () => false,
|
|
141
|
+
validOption: () => true,
|
|
142
|
+
validOptionCategory: () => true
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 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
|
+
* dispatch transitions every field to its loaded value in one atomic update.
|
|
149
|
+
*
|
|
150
|
+
* No constructor parameters because the variant data (mode, validators, configuredOptions) is not yet available at store-construction time - it arrives over the
|
|
151
|
+
* wire from Homebridge's `getPluginConfig` + `request("/getOptions")` plus the plugin's optional `getControllers`. The orchestrator dispatches `model:loaded` once
|
|
152
|
+
* those resolve.
|
|
153
|
+
*
|
|
154
|
+
* @returns {FeatureOptionsState} A fresh initial-state object.
|
|
155
|
+
*/
|
|
156
|
+
export const initialState = () => {
|
|
157
|
+
|
|
158
|
+
// Share a single empty-array reference across every array-typed option field so the persist effect's "are we dirty?" check (configuredOptions === persistedAnchor)
|
|
159
|
+
// 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.
|
|
161
|
+
const empty = [];
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
|
|
165
|
+
catalog: EMPTY_CATALOG,
|
|
166
|
+
configuredOptions: empty,
|
|
167
|
+
controllers: [],
|
|
168
|
+
devices: [],
|
|
169
|
+
devicesControllerId: null,
|
|
170
|
+
filter: { mode: "all", query: "" },
|
|
171
|
+
initialOptions: empty,
|
|
172
|
+
mode: "device-only",
|
|
173
|
+
persistedAnchor: empty,
|
|
174
|
+
scope: { kind: "global" },
|
|
175
|
+
status: { kind: "loading" }
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The pure reducer. Applies an action to the current state and returns the new state. Structural sharing: unchanged slices retain their reference across the
|
|
181
|
+
* transition, so memoized selectors that depend on those slices return cached results.
|
|
182
|
+
*
|
|
183
|
+
* Unknown action types throw - silently ignoring them would let typo bugs escape into production where they manifest as missing UI updates. Every legitimate
|
|
184
|
+
* action lives below; an unknown type is a bug at the dispatch site, surfaced loudly.
|
|
185
|
+
*
|
|
186
|
+
* @param {FeatureOptionsState} state - The current state.
|
|
187
|
+
* @param {{type: string}} action - The action to apply. Discriminated by `type`; the switch below enumerates every legitimate value.
|
|
188
|
+
* @returns {FeatureOptionsState} The new state.
|
|
189
|
+
*/
|
|
190
|
+
export const reducer = (state, action) => {
|
|
191
|
+
|
|
192
|
+
switch(action.type) {
|
|
193
|
+
|
|
194
|
+
case "model:loaded": {
|
|
195
|
+
|
|
196
|
+
// First load: catalog, configuredOptions, mode, controllers populated. The persistence anchor seeds from the just-loaded options (pre-mutation the loaded array
|
|
197
|
+
// IS the disk state). The initial snapshot - the revert target - takes `action.initialOptions` if the dispatcher supplied it (orchestrator re-shows that
|
|
198
|
+
// detected set-equal options carry the original snapshot forward), otherwise falls back to the loaded options. Status transitions to ready.
|
|
199
|
+
return {
|
|
200
|
+
|
|
201
|
+
...state,
|
|
202
|
+
catalog: action.catalog,
|
|
203
|
+
configuredOptions: action.configuredOptions,
|
|
204
|
+
controllers: action.controllers,
|
|
205
|
+
initialOptions: action.initialOptions ?? action.configuredOptions,
|
|
206
|
+
mode: action.mode,
|
|
207
|
+
persistedAnchor: action.configuredOptions,
|
|
208
|
+
status: { kind: "ready" }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
case "controllers:loaded": {
|
|
213
|
+
|
|
214
|
+
// Controllers list refreshed without re-loading the model - a controllers-only refresh hook that no code currently dispatches (nav rebuilds the sidebar on it).
|
|
215
|
+
return { ...state, controllers: action.controllers };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case "devices:loaded": {
|
|
219
|
+
|
|
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 };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case "scope:changed": {
|
|
227
|
+
|
|
228
|
+
// Replace the selection pointer wholesale. The DU is atomic - controllerId, deviceId, and kind all move together so subscribers never observe a partial
|
|
229
|
+
// selection state.
|
|
230
|
+
return { ...state, scope: action.scope };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case "option:set": {
|
|
234
|
+
|
|
235
|
+
// Compute the new configuredOptions via the pure transform. The transform returns a fresh array so reference equality on configuredOptions detects the
|
|
236
|
+
// change; the catalog reference is unchanged so selectors that depend only on the catalog continue to hit their caches.
|
|
237
|
+
return {
|
|
238
|
+
|
|
239
|
+
...state,
|
|
240
|
+
configuredOptions: applySetOption({ args: action.args, catalog: state.catalog, configuredOptions: state.configuredOptions })
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "option:cleared": {
|
|
245
|
+
|
|
246
|
+
// The pure transform returns the input reference unchanged when nothing matched, so the reducer's `...state, configuredOptions: ...` spread also yields a
|
|
247
|
+
// state value whose configuredOptions reference equals the prior one. Subscribers reading reference equality see a no-op and skip recomputation.
|
|
248
|
+
return {
|
|
249
|
+
|
|
250
|
+
...state,
|
|
251
|
+
configuredOptions: applyClearOption({ args: action.args, catalog: state.catalog, configuredOptions: state.configuredOptions })
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case "options:reset": {
|
|
256
|
+
|
|
257
|
+
// Wipe every configured option. Fresh empty array so reference equality reflects the change. The initial snapshot and the anchor are untouched - revert
|
|
258
|
+
// and persist failure still have their respective rollback targets.
|
|
259
|
+
return { ...state, configuredOptions: [] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case "model:reverted": {
|
|
263
|
+
|
|
264
|
+
// Restore configuredOptions to the at-show() snapshot. The snapshot reference is reused as-is - subsequent option:set / option:cleared mutations will fork
|
|
265
|
+
// it via the pure transforms, so the snapshot stays stable for the next revert.
|
|
266
|
+
return { ...state, configuredOptions: state.initialOptions };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
case "filter:changed": {
|
|
270
|
+
|
|
271
|
+
// Partial update: action fields override; absent fields preserve. The action shape carries optional `query` and `mode` so callers can update one axis without
|
|
272
|
+
// touching the other. A new filter object is allocated so reference equality reflects the change.
|
|
273
|
+
return {
|
|
274
|
+
|
|
275
|
+
...state,
|
|
276
|
+
filter: {
|
|
277
|
+
|
|
278
|
+
mode: action.mode ?? state.filter.mode,
|
|
279
|
+
query: action.query ?? state.filter.query
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case "persist:started": {
|
|
285
|
+
|
|
286
|
+
// Status transitions to persisting, carrying the snapshot that's now in flight. Subscribers (status bar) can show a "saving" affordance and read the snapshot
|
|
287
|
+
// when they need to know what's pending.
|
|
288
|
+
return { ...state, status: { kind: "persisting", snapshot: action.snapshot } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case "persist:succeeded": {
|
|
292
|
+
|
|
293
|
+
// The in-flight snapshot landed on disk. Promote it to the anchor so any subsequent rollback (after a future failure) restores to this state, and return
|
|
294
|
+
// status to ready.
|
|
295
|
+
return { ...state, persistedAnchor: action.snapshot, status: { kind: "ready" } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case "persist:failed": {
|
|
299
|
+
|
|
300
|
+
// Final-attempt failure with no superseding mutation. Roll configuredOptions back to the last-known disk state so memory matches disk, and transition status
|
|
301
|
+
// to persist-error so subscribers (status bar / toast emitter) can surface the failure.
|
|
302
|
+
return { ...state, configuredOptions: state.persistedAnchor, status: { error: action.error, kind: "persist-error" } };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
case "connection:error": {
|
|
306
|
+
|
|
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 } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
default: {
|
|
313
|
+
|
|
314
|
+
// Unknown action: bug at the dispatch site. Surface loudly rather than silently ignore - the user's reported "UI didn't update" stack trace is more useful
|
|
315
|
+
// than a months-later realization that a typo was eating dispatches.
|
|
316
|
+
throw new Error("FeatureOptionsState.reducer: unknown action type \"" + action.type + "\".");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|