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.
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 +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  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 -262
  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 +49 -2
  55. package/dist/ffmpeg/fmp4.js +47 -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 +142 -96
  73. package/dist/ffmpeg/process.js +406 -278
  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 -567
  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 -251
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -2
  88. package/dist/ffmpeg/settings.js +20 -5
  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 -150
  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 -6
  100. package/dist/index.js +7 -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 +602 -45
  186. package/dist/util.js +783 -78
  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,77 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * webUi-featureOptions/views/header.mjs: The priority-chain informational header.
4
+ */
5
+ "use strict";
6
+
7
+ import { createElement } from "../utils.mjs";
8
+ import { effect } from "../store.mjs";
9
+
10
+ /**
11
+ * Mount the priority-chain header view.
12
+ *
13
+ * The header reads as the literal hierarchy users see in the UI: "Global options -> Controller options -> Device options," with the controller hop omitted in
14
+ * device-only mode. Bold lead-in text frames it as a precedence statement; color-coded labels (warning / success / info) match the same scope-color convention the
15
+ * row labels use, so users have one consistent visual lens for "where does a setting come from."
16
+ *
17
+ * Runs on `model:loaded` and `connection:error`; all other dispatches never invoke it because they are not subscribed. On `connection:error` (and the loading
18
+ * status), `fn` yields inside its `status.kind` checks. In practice the header content is rendered once, at `model:loaded`. The connection-error and no-controllers
19
+ * views render their own content into the same container, so this view yields when the status indicates either of those states.
20
+ *
21
+ * @param {Object} args
22
+ * @param {HTMLElement} args.root - The `#headerInfo` container.
23
+ * @param {AbortSignal} args.signal - Lifecycle signal.
24
+ * @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
25
+ */
26
+ export const mountHeaderView = ({ root, signal, store }) => {
27
+
28
+ effect({
29
+
30
+ events: [ "model:loaded", "connection:error" ],
31
+ fn: () => {
32
+
33
+ const { mode, status } = store.state;
34
+
35
+ // Yield to the connection-error view when an error is active - that view owns the header content in error states.
36
+ if(status.kind === "connection-error") {
37
+
38
+ return;
39
+ }
40
+
41
+ // Defensive guard against a "loading" status at mount time. In the current call order this branch is unreachable: the no-controllers path returns from
42
+ // show() before any views mount, and the success path dispatches model:loaded - which sets status to ready - before mountHeaderView runs. The check stays
43
+ // in place as a safeguard against a future reordering that mounts views before model:loaded fires.
44
+ if(status.kind === "loading") {
45
+
46
+ return;
47
+ }
48
+
49
+ // Style the header text. The view never reveals its own region: the orchestrator owns region visibility, revealing every populated region together via
50
+ // revealRegions on the success path and the #headerInfo reveal on the no-controllers path. On the connection-error path the connection-error view owns the
51
+ // #headerInfo reveal (it alone has the error content to show). Either way the populated UI appears in one coordinated reveal rather than region-by-region as
52
+ // each view mounts.
53
+ root.style.fontWeight = "bold";
54
+
55
+ // Build the precedence chain via DOM nodes rather than innerHTML. The controller hop is conditional, so we assemble children imperatively before handing
56
+ // them to replaceChildren in one mutation.
57
+ const children = [
58
+
59
+ "Feature options are applied in prioritized order, from global to device-specific options:",
60
+ createElement("br"),
61
+ createElement("i", { classList: ["text-warning"] }, ["Global options"]),
62
+ " (lowest priority) -> "
63
+ ];
64
+
65
+ if(mode === "controller-based") {
66
+
67
+ children.push(createElement("i", { classList: ["text-success"] }, ["Controller options"]), " -> ");
68
+ }
69
+
70
+ children.push(createElement("i", { classList: ["text-info"] }, ["Device options"]), " (highest priority)");
71
+
72
+ root.replaceChildren(...children);
73
+ },
74
+ signal,
75
+ store
76
+ });
77
+ };
@@ -0,0 +1,327 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * webUi-featureOptions/views/nav.mjs: Sidebar navigation - controllers + devices + scope highlighting + click dispatch.
4
+ */
5
+ "use strict";
6
+
7
+ import { createElement } from "../utils.mjs";
8
+ import { effect } from "../store.mjs";
9
+
10
+ /**
11
+ * Mount the sidebar navigation view.
12
+ *
13
+ * The sidebar has two containers (controllers + devices) and the following kinds of links:
14
+ *
15
+ * - **Global Options** (always present, in the controllers container): `data-navigation="global"`. Clicked -> dispatch `scope:changed` with `kind: "global"`.
16
+ * - **Controller links** (one per controller, in the controllers container, only when mode is controller-based): `data-navigation="controller"` +
17
+ * `data-device-serial=<serial>`. Clicked -> dispatch `scope:changed` with `kind: "controller"` AND fire `getDevices` for the new controller.
18
+ * - **Device links** (one per device, in the devices container, when devices for the active controller are loaded): `data-navigation="device"` +
19
+ * `data-device-serial=<serial>`. Clicked -> dispatch `scope:changed` with `kind: "device"`.
20
+ *
21
+ * Devices group themselves by an optional `sidebarGroup` property: ungrouped devices appear first under the device-label header, then groups appear with their
22
+ * own headers in alphabetical order. The reserved group name "hidden" excludes devices from the sidebar entirely.
23
+ *
24
+ * Subscribes to:
25
+ *
26
+ * - `controllers:loaded` - rebuild the controllers container (a controllers-only refresh hook not currently dispatched).
27
+ * - `devices:loaded` - rebuild the devices container.
28
+ * - `scope:changed` - update active-link highlighting without rebuilding.
29
+ * - `model:loaded` - initial build (controllers + global link + mode-aware structure).
30
+ *
31
+ * The controller-click handler does I/O: it calls the caller-supplied `getDevices` callback to fetch the new controller's devices, then dispatches a
32
+ * `devices:loaded` action. The dispatch is wrapped in a try/catch so a failing fetch surfaces as a `connection:error` action; the view layer never silently
33
+ * swallows a network failure.
34
+ *
35
+ * @param {Object} args
36
+ * @param {((controller: import("../state.mjs").Controller | null) =>
37
+ * Promise<readonly import("../state.mjs").Device[]>) | undefined} args.getDevices
38
+ * - Plugin-provided fetcher for a controller's devices. Called on controller-link click.
39
+ * @param {string} args.labelControllers - Section header label for the controllers list.
40
+ * @param {string} args.labelDevices - Section header label for the devices list.
41
+ * @param {{ request: (path: string) => Promise<unknown> }} args.host - Homebridge bridge (used to fetch the error message on connection failure).
42
+ * @param {HTMLElement} args.rootControllers - The `#controllersContainer` element.
43
+ * @param {HTMLElement} args.rootDevices - The `#devicesContainer` element.
44
+ * @param {AbortSignal} args.signal - Lifecycle signal.
45
+ * @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
46
+ */
47
+ export const mountNavView = ({ getDevices, host, labelControllers, labelDevices, rootControllers, rootDevices, signal, store }) => {
48
+
49
+ // Controllers container rebuilds on model:loaded (initial mode/controllers), plus controllers:loaded - a controllers-only refresh hook not currently dispatched.
50
+ effect({
51
+
52
+ events: [ "controllers:loaded", "model:loaded" ],
53
+ fn: () => {
54
+
55
+ if(store.state.status.kind === "loading") {
56
+
57
+ return;
58
+ }
59
+
60
+ buildControllersList({ controllerLabel: labelControllers, mode: store.state.mode, root: rootControllers, state: store.state });
61
+ applyControllersHighlight(rootControllers, store.state.scope, store.state.devicesControllerId);
62
+ },
63
+ signal,
64
+ store
65
+ });
66
+
67
+ // Devices container rebuilds on model:loaded (initial structure) and devices:loaded (new controller selected). Active-link highlighting on scope:changed is the
68
+ // separate effect below.
69
+ effect({
70
+
71
+ events: [ "devices:loaded", "model:loaded" ],
72
+ fn: () => {
73
+
74
+ if(store.state.status.kind === "loading") {
75
+
76
+ return;
77
+ }
78
+
79
+ buildDevicesList({ catalog: store.state.catalog, deviceLabel: labelDevices, devices: store.state.devices, root: rootDevices });
80
+ applyDevicesHighlight(rootDevices, store.state.scope);
81
+ },
82
+ signal,
83
+ store
84
+ });
85
+
86
+ // scope:changed updates highlighting on both containers without rebuilding their content.
87
+ effect({
88
+
89
+ events: ["scope:changed"],
90
+ fn: () => {
91
+
92
+ applyControllersHighlight(rootControllers, store.state.scope, store.state.devicesControllerId);
93
+ applyDevicesHighlight(rootDevices, store.state.scope);
94
+ },
95
+ signal,
96
+ store
97
+ });
98
+
99
+ // Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change.
100
+ const onClick = (event) => handleNavClick({ event, getDevices, host, signal, store });
101
+
102
+ rootControllers.addEventListener("click", onClick, { signal });
103
+ rootDevices.addEventListener("click", onClick, { signal });
104
+ };
105
+
106
+ // Build a controller / device navigation link. Both kinds share the same class set and accessibility shape, differing only in their navigation marker and serial, so
107
+ // one factory keeps that shape in a single place. The Global Options link is built inline in buildControllersList because it carries a distinct header-style class set.
108
+ const navLink = ({ label, navigation, serial }) => createElement("a", {
109
+
110
+ classList: [ "nav-link", "text-decoration-none" ],
111
+ "data-device-serial": serial,
112
+ "data-navigation": navigation,
113
+ href: "#",
114
+ role: "button"
115
+ }, [label]);
116
+
117
+ // Build a section header. The controllers section, the ungrouped-device list, and every device group share the same header markup, so one factory is the single
118
+ // source of that shape.
119
+ const sectionHeader = (label) => createElement("h6", {
120
+
121
+ classList: [ "nav-header", "text-muted", "text-uppercase", "small", "mb-1" ]
122
+ }, [label]);
123
+
124
+ // Append a labeled section to a container: an optional header followed by one rendered node per item. The header renders only when the section has at least one item,
125
+ // so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section" invariant - it makes an orphan
126
+ // header (a label with no items beneath it) unrepresentable regardless of which list a caller renders, which is what keeps a fully-grouped device set from showing a
127
+ // standalone top-level header that labels nothing.
128
+ const appendSection = ({ items, label, render, root }) => {
129
+
130
+ if(!items.length) {
131
+
132
+ return;
133
+ }
134
+
135
+ if(label) {
136
+
137
+ root.appendChild(sectionHeader(label));
138
+ }
139
+
140
+ for(const item of items) {
141
+
142
+ root.appendChild(render(item));
143
+ }
144
+ };
145
+
146
+ // Build the controllers container: the always-present Global Options link, then - in controller-based mode - the controllers section. The Global Options link carries
147
+ // its own header-style class set (bold, uppercase) and is always present, so it is built inline rather than through appendSection.
148
+ const buildControllersList = ({ controllerLabel, mode, root, state }) => {
149
+
150
+ root.textContent = "";
151
+
152
+ root.appendChild(createElement("a", {
153
+
154
+ classList: [ "nav-link", "nav-header", "text-decoration-none", "text-uppercase", "fw-bold" ],
155
+ "data-navigation": "global",
156
+ href: "#",
157
+ role: "button"
158
+ }, ["Global Options"]));
159
+
160
+ if(mode !== "controller-based") {
161
+
162
+ return;
163
+ }
164
+
165
+ // The controllers section. In controller-based mode there is always at least one controller by the time the sidebar builds (the orchestrator shows the
166
+ // no-controllers message and never mounts the nav otherwise), so the non-empty guard is belt-and-suspenders here - but routing through appendSection keeps every
167
+ // section under one invariant rather than special-casing this one.
168
+ appendSection({
169
+
170
+ items: state.controllers,
171
+ label: controllerLabel,
172
+ render: (controller) => navLink({ label: controller.name, navigation: "controller", serial: controller.serialNumber }),
173
+ root
174
+ });
175
+ };
176
+
177
+ // Build the devices container. The ungrouped devices form the top-level section under the device label; each sidebarGroup forms its own section in alphabetical order.
178
+ // Because the device-label header renders only when there is at least one ungrouped device (the appendSection invariant), a fully-grouped device set - every device
179
+ // carrying a sidebarGroup - shows its group headers alone, with no orphan top-level device header. Controllers are excluded from group derivation (their link lives in
180
+ // the controllers container above); the reserved "hidden" group excludes devices from the sidebar entirely.
181
+ const buildDevicesList = ({ catalog, deviceLabel, devices, root }) => {
182
+
183
+ root.textContent = "";
184
+
185
+ if(!devices.length) {
186
+
187
+ return;
188
+ }
189
+
190
+ const isController = catalog.validators.isController;
191
+ const renderDevice = (device) => navLink({ label: device.name ?? "Unknown", navigation: "device", serial: device.serialNumber });
192
+
193
+ // Ungrouped devices, headed by the device label. appendSection suppresses the header when there are no ungrouped devices.
194
+ appendSection({ items: devices.filter((device) => !device.sidebarGroup), label: deviceLabel, render: renderDevice, root });
195
+
196
+ // Grouped devices, each group its own section in alphabetical order. Group derivation excludes controllers and the reserved "hidden" group.
197
+ const groups = [...new Set(devices
198
+
199
+ .filter((device) => !isController(device) && device.sidebarGroup && (device.sidebarGroup !== "hidden"))
200
+ .map((device) => device.sidebarGroup))].sort();
201
+
202
+ for(const group of groups) {
203
+
204
+ appendSection({ items: devices.filter((device) => device.sidebarGroup === group), label: group, render: renderDevice, root });
205
+ }
206
+ };
207
+
208
+ // Highlight the controller link matching the current scope, and mark the in-scope controller. The Global Options link activates only for a true global scope
209
+ // (`scope.kind === "global"`), so a device-only device scope - which carries a null controllerId - does not light Global; only the device link
210
+ // lights in that case. A controller link activates when its serial matches the scope's controllerId; no controller link activates when that serial is absent (a
211
+ // global scope, or a controllerId not in the current list). Separately, the controller whose devices are currently loaded (`devicesControllerId`) carries the
212
+ // `context` class so the sidebar can outline it - the affordance that keeps the device list's owning controller identifiable even when the active selection is
213
+ // Global (the CSS suppresses it when the entry is `active`).
214
+ const applyControllersHighlight = (root, scope, devicesControllerId) => {
215
+
216
+ const targetSerial = (scope.kind === "global") ? null : scope.controllerId;
217
+
218
+ for(const entry of root.querySelectorAll(".nav-link[data-navigation]")) {
219
+
220
+ const isGlobal = entry.getAttribute("data-navigation") === "global";
221
+ const serial = entry.getAttribute("data-device-serial");
222
+ const matches = isGlobal ? (scope.kind === "global") : (serial === targetSerial);
223
+
224
+ entry.classList.toggle("active", matches);
225
+ entry.classList.toggle("context", !isGlobal && (devicesControllerId !== null) && (serial === devicesControllerId));
226
+ }
227
+ };
228
+
229
+ // Highlight the device link matching the current scope. Device scope activates the matching device link; any other scope kind deactivates every device link.
230
+ const applyDevicesHighlight = (root, scope) => {
231
+
232
+ const targetSerial = (scope.kind === "device") ? scope.deviceId : null;
233
+
234
+ for(const entry of root.querySelectorAll(".nav-link[data-navigation]")) {
235
+
236
+ entry.classList.toggle("active", entry.getAttribute("data-device-serial") === targetSerial);
237
+ }
238
+ };
239
+
240
+ // Handle a click on any nav link. Resolves the click target's `data-navigation` and dispatches the corresponding scope-change. Controller clicks additionally
241
+ // fetch the new controller's devices via the caller-supplied `getDevices` callback.
242
+ const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
243
+
244
+ const navLink = event.target.closest(".nav-link[data-navigation]");
245
+
246
+ if(!navLink) {
247
+
248
+ return;
249
+ }
250
+
251
+ event.preventDefault();
252
+
253
+ const navigation = navLink.getAttribute("data-navigation");
254
+ const deviceSerial = navLink.getAttribute("data-device-serial");
255
+
256
+ switch(navigation) {
257
+
258
+ case "global": {
259
+
260
+ store.dispatch({ scope: { kind: "global" }, type: "scope:changed" });
261
+
262
+ return;
263
+ }
264
+
265
+ case "controller": {
266
+
267
+ // Optimistic scope update before the fetch so the sidebar highlight repaints immediately. If the fetch fails we transition to connection:error; if it
268
+ // succeeds we dispatch devices:loaded and select the controller-as-device entry (the first device in the returned list).
269
+ store.dispatch({ scope: { controllerId: deviceSerial, kind: "controller" }, type: "scope:changed" });
270
+
271
+ if(!getDevices) {
272
+
273
+ return;
274
+ }
275
+
276
+ try {
277
+
278
+ const controller = store.state.controllers.find((c) => c.serialNumber === deviceSerial);
279
+ const devices = await getDevices(controller ?? null);
280
+
281
+ if(signal.aborted) {
282
+
283
+ return;
284
+ }
285
+
286
+ store.dispatch({ controllerId: deviceSerial, devices, type: "devices:loaded" });
287
+
288
+ if(devices.length === 0) {
289
+
290
+ // No devices returned despite a controller in scope - treat as a connection failure and surface the upstream error message via the host.
291
+ const message = String(await host.request("/getErrorMessage") ?? "");
292
+
293
+ if(signal.aborted) {
294
+
295
+ return;
296
+ }
297
+
298
+ store.dispatch({ message, type: "connection:error" });
299
+
300
+ return;
301
+ }
302
+
303
+ // Select the controller-as-device entry (the first device in the returned list).
304
+ store.dispatch({ scope: { controllerId: deviceSerial, deviceId: devices[0].serialNumber, kind: "device" }, type: "scope:changed" });
305
+ } catch {
306
+
307
+ if(signal.aborted) {
308
+
309
+ return;
310
+ }
311
+
312
+ store.dispatch({ message: "Failed to fetch devices.", type: "connection:error" });
313
+ }
314
+
315
+ return;
316
+ }
317
+
318
+ case "device": {
319
+
320
+ // The device's parent controller is the one whose device list this device belongs to, not the live scope's controller - arriving here from global scope, the
321
+ // scope carries no controller, so reading it would drop the parent and mis-highlight Global. `devicesControllerId` preserves the association.
322
+ const controllerId = store.state.devicesControllerId;
323
+
324
+ store.dispatch({ scope: { controllerId, deviceId: deviceSerial, kind: "device" }, type: "scope:changed" });
325
+ }
326
+ }
327
+ };