homebridge-plugin-utils 1.35.0 → 2.1.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 +166 -0
- package/dist/cli/index.js +551 -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/disposable-stack.d.ts +59 -0
- package/dist/disposable-stack.js +155 -0
- package/dist/disposable-stack.js.map +1 -0
- package/dist/docChrome.d.ts +260 -0
- package/dist/docChrome.js +361 -0
- package/dist/docChrome.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 +161 -0
- package/dist/ffmpeg/mp4-assembler.js +424 -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 +325 -186
- package/dist/ffmpeg/record.js +418 -565
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +157 -0
- package/dist/ffmpeg/recording-process-double.js +190 -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 +474 -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 +13 -6
- package/dist/index.js +8 -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 +213 -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 +341 -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 +702 -0
- package/dist/ui/webUi.mjs +225 -88
- package/dist/util.d.ts +679 -45
- package/dist/util.js +830 -77
- 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,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,341 @@
|
|
|
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 DeviceListResult, then dispatches a
|
|
32
|
+
* `devices:loaded` action followed by either a scope change (devices present) or a `connection:error` (the result carried a failure message). A per-mount fetch
|
|
33
|
+
* generation guards the last-resolved-wins race: the newest click owns the store, so a superseded controller click discards its result on both settlement paths
|
|
34
|
+
* (resolve and reject) rather than overwriting the newer click's rendered state. The handler wraps its fetch in a try/catch so a rejected fetch surfaces as a
|
|
35
|
+
* `connection:error` action rather than an unhandled rejection; the view layer never silently swallows a failure.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} args
|
|
38
|
+
* @param {((controller: import("../state.mjs").Controller | null) =>
|
|
39
|
+
* Promise<import("../../webUi-featureOptions.mjs").DeviceListResult>) | undefined} args.getDevices
|
|
40
|
+
* - Plugin-provided fetcher resolving a controller's DeviceListResult. Called on controller-link click.
|
|
41
|
+
* @param {string} args.labelControllers - Section header label for the controllers list.
|
|
42
|
+
* @param {string} args.labelDevices - Section header label for the devices list.
|
|
43
|
+
* @param {HTMLElement} args.rootControllers - The `#controllersContainer` element.
|
|
44
|
+
* @param {HTMLElement} args.rootDevices - The `#devicesContainer` element.
|
|
45
|
+
* @param {AbortSignal} args.signal - Lifecycle signal.
|
|
46
|
+
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
|
|
47
|
+
*/
|
|
48
|
+
export const mountNavView = ({ getDevices, labelControllers, labelDevices, rootControllers, rootDevices, signal, store }) => {
|
|
49
|
+
|
|
50
|
+
// Controllers container rebuilds on model:loaded (initial mode/controllers), plus controllers:loaded - a controllers-only refresh hook not currently dispatched.
|
|
51
|
+
effect({
|
|
52
|
+
|
|
53
|
+
events: [ "controllers:loaded", "model:loaded" ],
|
|
54
|
+
fn: () => {
|
|
55
|
+
|
|
56
|
+
if(store.state.status.kind === "loading") {
|
|
57
|
+
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
buildControllersList({ controllerLabel: labelControllers, mode: store.state.mode, root: rootControllers, state: store.state });
|
|
62
|
+
applyControllersHighlight(rootControllers, store.state.scope, store.state.devicesControllerId);
|
|
63
|
+
},
|
|
64
|
+
signal,
|
|
65
|
+
store
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Devices container rebuilds on model:loaded (initial structure) and devices:loaded (new controller selected). Active-link highlighting on scope:changed is the
|
|
69
|
+
// separate effect below.
|
|
70
|
+
effect({
|
|
71
|
+
|
|
72
|
+
events: [ "devices:loaded", "model:loaded" ],
|
|
73
|
+
fn: () => {
|
|
74
|
+
|
|
75
|
+
if(store.state.status.kind === "loading") {
|
|
76
|
+
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
buildDevicesList({ catalog: store.state.catalog, deviceLabel: labelDevices, devices: store.state.devices, root: rootDevices });
|
|
81
|
+
applyDevicesHighlight(rootDevices, store.state.scope);
|
|
82
|
+
},
|
|
83
|
+
signal,
|
|
84
|
+
store
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// scope:changed updates highlighting on both containers without rebuilding their content.
|
|
88
|
+
effect({
|
|
89
|
+
|
|
90
|
+
events: ["scope:changed"],
|
|
91
|
+
fn: () => {
|
|
92
|
+
|
|
93
|
+
applyControllersHighlight(rootControllers, store.state.scope, store.state.devicesControllerId);
|
|
94
|
+
applyDevicesHighlight(rootDevices, store.state.scope);
|
|
95
|
+
},
|
|
96
|
+
signal,
|
|
97
|
+
store
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Per-mount fetch generation for the controller-click handler. Held as a mutable reference (not a plain number) because handleNavClick is module-scope: a number
|
|
101
|
+
// would be copied by value into the call, defeating the guard. The closure-held object mirrors the connection-error view's retryAbort per-mount state. Each
|
|
102
|
+
// controller click captures the incremented generation before its fetch; a continuation whose generation no longer matches has been superseded by a newer click and
|
|
103
|
+
// discards its result.
|
|
104
|
+
const clickFetch = { generation: 0 };
|
|
105
|
+
|
|
106
|
+
// Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change.
|
|
107
|
+
const onClick = (event) => handleNavClick({ clickFetch, event, getDevices, signal, store });
|
|
108
|
+
|
|
109
|
+
rootControllers.addEventListener("click", onClick, { signal });
|
|
110
|
+
rootDevices.addEventListener("click", onClick, { signal });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// 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
|
|
114
|
+
// 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.
|
|
115
|
+
const navLink = ({ label, navigation, serial }) => createElement("a", {
|
|
116
|
+
|
|
117
|
+
classList: [ "nav-link", "text-decoration-none" ],
|
|
118
|
+
"data-device-serial": serial,
|
|
119
|
+
"data-navigation": navigation,
|
|
120
|
+
href: "#",
|
|
121
|
+
role: "button"
|
|
122
|
+
}, [label]);
|
|
123
|
+
|
|
124
|
+
// 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
|
|
125
|
+
// source of that shape.
|
|
126
|
+
const sectionHeader = (label) => createElement("h6", {
|
|
127
|
+
|
|
128
|
+
classList: [ "nav-header", "text-muted", "text-uppercase", "small", "mb-1" ]
|
|
129
|
+
}, [label]);
|
|
130
|
+
|
|
131
|
+
// 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,
|
|
132
|
+
// so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section" rule - it makes an orphan
|
|
133
|
+
// 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
|
|
134
|
+
// standalone top-level header that labels nothing.
|
|
135
|
+
const appendSection = ({ items, label, render, root }) => {
|
|
136
|
+
|
|
137
|
+
if(!items.length) {
|
|
138
|
+
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if(label) {
|
|
143
|
+
|
|
144
|
+
root.appendChild(sectionHeader(label));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for(const item of items) {
|
|
148
|
+
|
|
149
|
+
root.appendChild(render(item));
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Build the controllers container: the always-present Global Options link, then - in controller-based mode - the controllers section. The Global Options link carries
|
|
154
|
+
// its own header-style class set (bold, uppercase) and is always present, so it is built inline rather than through appendSection.
|
|
155
|
+
const buildControllersList = ({ controllerLabel, mode, root, state }) => {
|
|
156
|
+
|
|
157
|
+
root.textContent = "";
|
|
158
|
+
|
|
159
|
+
root.appendChild(createElement("a", {
|
|
160
|
+
|
|
161
|
+
classList: [ "nav-link", "nav-header", "text-decoration-none", "text-uppercase", "fw-bold" ],
|
|
162
|
+
"data-navigation": "global",
|
|
163
|
+
href: "#",
|
|
164
|
+
role: "button"
|
|
165
|
+
}, ["Global Options"]));
|
|
166
|
+
|
|
167
|
+
if(mode !== "controller-based") {
|
|
168
|
+
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The controllers section. In controller-based mode there is always at least one controller by the time the sidebar builds (the orchestrator shows the
|
|
173
|
+
// 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
|
|
174
|
+
// section under one rule rather than special-casing this one.
|
|
175
|
+
appendSection({
|
|
176
|
+
|
|
177
|
+
items: state.controllers,
|
|
178
|
+
label: controllerLabel,
|
|
179
|
+
render: (controller) => navLink({ label: controller.name, navigation: "controller", serial: controller.serialNumber }),
|
|
180
|
+
root
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// 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.
|
|
185
|
+
// Because the device-label header renders only when there is at least one ungrouped device (the appendSection rule), a fully-grouped device set - every device
|
|
186
|
+
// 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
|
|
187
|
+
// the controllers container above); the reserved "hidden" group excludes devices from the sidebar entirely.
|
|
188
|
+
const buildDevicesList = ({ catalog, deviceLabel, devices, root }) => {
|
|
189
|
+
|
|
190
|
+
root.textContent = "";
|
|
191
|
+
|
|
192
|
+
if(!devices.length) {
|
|
193
|
+
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const isController = catalog.validators.isController;
|
|
198
|
+
const renderDevice = (device) => navLink({ label: device.name ?? "Unknown", navigation: "device", serial: device.serialNumber });
|
|
199
|
+
|
|
200
|
+
// Ungrouped devices, headed by the device label. appendSection suppresses the header when there are no ungrouped devices.
|
|
201
|
+
appendSection({ items: devices.filter((device) => !device.sidebarGroup), label: deviceLabel, render: renderDevice, root });
|
|
202
|
+
|
|
203
|
+
// Grouped devices, each group its own section in alphabetical order. Group derivation excludes controllers and the reserved "hidden" group.
|
|
204
|
+
const groups = [...new Set(devices
|
|
205
|
+
|
|
206
|
+
.filter((device) => !isController(device) && device.sidebarGroup && (device.sidebarGroup !== "hidden"))
|
|
207
|
+
.map((device) => device.sidebarGroup))].sort();
|
|
208
|
+
|
|
209
|
+
for(const group of groups) {
|
|
210
|
+
|
|
211
|
+
appendSection({ items: devices.filter((device) => device.sidebarGroup === group), label: group, render: renderDevice, root });
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// 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
|
|
216
|
+
// (`scope.kind === "global"`), so a device-only device scope - which carries a null controllerId - does not light Global; only the device link
|
|
217
|
+
// 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
|
|
218
|
+
// global scope, or a controllerId not in the current list). Separately, the controller whose devices are currently loaded (`devicesControllerId`) carries the
|
|
219
|
+
// `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
|
|
220
|
+
// Global (the CSS suppresses it when the entry is `active`).
|
|
221
|
+
const applyControllersHighlight = (root, scope, devicesControllerId) => {
|
|
222
|
+
|
|
223
|
+
const targetSerial = (scope.kind === "global") ? null : scope.controllerId;
|
|
224
|
+
|
|
225
|
+
for(const entry of root.querySelectorAll(".nav-link[data-navigation]")) {
|
|
226
|
+
|
|
227
|
+
const isGlobal = entry.getAttribute("data-navigation") === "global";
|
|
228
|
+
const serial = entry.getAttribute("data-device-serial");
|
|
229
|
+
const matches = isGlobal ? (scope.kind === "global") : (serial === targetSerial);
|
|
230
|
+
|
|
231
|
+
entry.classList.toggle("active", matches);
|
|
232
|
+
entry.classList.toggle("context", !isGlobal && (devicesControllerId !== null) && (serial === devicesControllerId));
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Highlight the device link matching the current scope. Device scope activates the matching device link; any other scope kind deactivates every device link.
|
|
237
|
+
const applyDevicesHighlight = (root, scope) => {
|
|
238
|
+
|
|
239
|
+
const targetSerial = (scope.kind === "device") ? scope.deviceId : null;
|
|
240
|
+
|
|
241
|
+
for(const entry of root.querySelectorAll(".nav-link[data-navigation]")) {
|
|
242
|
+
|
|
243
|
+
entry.classList.toggle("active", entry.getAttribute("data-device-serial") === targetSerial);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// Handle a click on any nav link. Resolves the click target's `data-navigation` and dispatches the corresponding scope-change. Controller clicks additionally
|
|
248
|
+
// fetch the new controller's DeviceListResult via the caller-supplied `getDevices` callback.
|
|
249
|
+
const handleNavClick = async ({ clickFetch, event, getDevices, signal, store }) => {
|
|
250
|
+
|
|
251
|
+
const navLink = event.target.closest(".nav-link[data-navigation]");
|
|
252
|
+
|
|
253
|
+
if(!navLink) {
|
|
254
|
+
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
|
|
260
|
+
const navigation = navLink.getAttribute("data-navigation");
|
|
261
|
+
const deviceSerial = navLink.getAttribute("data-device-serial");
|
|
262
|
+
|
|
263
|
+
switch(navigation) {
|
|
264
|
+
|
|
265
|
+
case "global": {
|
|
266
|
+
|
|
267
|
+
store.dispatch({ scope: { kind: "global" }, type: "scope:changed" });
|
|
268
|
+
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
case "controller": {
|
|
273
|
+
|
|
274
|
+
// Optimistic scope update before the fetch so the sidebar highlight repaints immediately. If the result carries a failure message we transition to
|
|
275
|
+
// connection:error; if it carries devices we select the controller-as-device entry (the first device in the returned list).
|
|
276
|
+
store.dispatch({ scope: { controllerId: deviceSerial, kind: "controller" }, type: "scope:changed" });
|
|
277
|
+
|
|
278
|
+
if(!getDevices) {
|
|
279
|
+
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Capture this fetch's generation before awaiting. The newest click owns the store, so a continuation whose generation no longer matches has been superseded by
|
|
284
|
+
// a later click and discards its result on both settlement paths below.
|
|
285
|
+
const generation = ++clickFetch.generation;
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
|
|
289
|
+
const controller = store.state.controllers.find((c) => c.serialNumber === deviceSerial);
|
|
290
|
+
const { devices, error } = await getDevices(controller ?? null);
|
|
291
|
+
|
|
292
|
+
// Bail if the page tore down or a newer click superseded this one; a stale continuation must not overwrite the newer click's rendered state.
|
|
293
|
+
if(signal.aborted || (generation !== clickFetch.generation)) {
|
|
294
|
+
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
store.dispatch({ controllerId: deviceSerial, devices, type: "devices:loaded" });
|
|
299
|
+
|
|
300
|
+
if(error.length) {
|
|
301
|
+
|
|
302
|
+
// The result carried a connection-failure message - hand the header to the connection-error view. The message arrived with the device-list response, so no
|
|
303
|
+
// separate request is made.
|
|
304
|
+
store.dispatch({ message: error, type: "connection:error" });
|
|
305
|
+
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if(devices.length === 0) {
|
|
310
|
+
|
|
311
|
+
// A legitimately empty controller: the optimistic controller scope stands over the empty list, with no device-scope dispatch.
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Select the controller-as-device entry (the first device in the returned list).
|
|
316
|
+
store.dispatch({ scope: { controllerId: deviceSerial, deviceId: devices[0].serialNumber, kind: "device" }, type: "scope:changed" });
|
|
317
|
+
} catch(err) {
|
|
318
|
+
|
|
319
|
+
// The same staleness bail guards the reject path: a superseded fetch that rejects (an IPC failure, the contract-guard TypeError) must not dispatch a stale
|
|
320
|
+
// connection:error over the newer click's state. A named Error reaches the user verbatim; non-Error junk falls back to the generic sentence.
|
|
321
|
+
if(signal.aborted || (generation !== clickFetch.generation)) {
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
store.dispatch({ message: (err instanceof Error) ? err.message : "Failed to fetch devices.", type: "connection:error" });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case "device": {
|
|
333
|
+
|
|
334
|
+
// 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
|
|
335
|
+
// scope carries no controller, so reading it would drop the parent and mis-highlight Global. `devicesControllerId` preserves the association.
|
|
336
|
+
const controllerId = store.state.devicesControllerId;
|
|
337
|
+
|
|
338
|
+
store.dispatch({ scope: { controllerId, deviceId: deviceSerial, kind: "device" }, type: "scope:changed" });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|