homebridge-plugin-utils 1.34.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +128 -0
  20. package/dist/backpressure.js +273 -0
  21. package/dist/backpressure.js.map +1 -0
  22. package/dist/cli/index.d.ts +95 -0
  23. package/dist/cli/index.js +355 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -257
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +70 -4
  55. package/dist/ffmpeg/fmp4.js +100 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +143 -91
  73. package/dist/ffmpeg/process.js +406 -265
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -560
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -244
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -1
  88. package/dist/ffmpeg/settings.js +20 -2
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -148
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -5
  100. package/dist/index.js +8 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +597 -45
  186. package/dist/util.js +787 -68
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -0,0 +1,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
+ };