@camstack/system 1.0.2
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/dist/addon/addon-api-factory.d.ts +35 -0
- package/dist/addon-routes/addon-route-registry.d.ts +37 -0
- package/dist/addon-runner.js +599 -0
- package/dist/addon-runner.mjs +597 -0
- package/dist/auth/api-key-manager.d.ts +26 -0
- package/dist/auth/auth-manager.d.ts +109 -0
- package/dist/auth/parse-record.d.ts +18 -0
- package/dist/auth/scope-matcher.d.ts +7 -0
- package/dist/auth/scoped-token-manager.d.ts +40 -0
- package/dist/auth/totp-manager.d.ts +51 -0
- package/dist/auth/user-manager.d.ts +34 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.d.ts +53 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +259 -0
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +251 -0
- package/dist/builtins/addon-pages-aggregator/dedupe-pages.d.ts +6 -0
- package/dist/builtins/addon-pages-aggregator/index.d.ts +1 -0
- package/dist/builtins/addon-pages-aggregator/index.js +8 -0
- package/dist/builtins/addon-pages-aggregator/index.mjs +2 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +47 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +228 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +220 -0
- package/dist/builtins/addon-widgets-aggregator/index.d.ts +1 -0
- package/dist/builtins/addon-widgets-aggregator/index.js +8 -0
- package/dist/builtins/addon-widgets-aggregator/index.mjs +2 -0
- package/dist/builtins/alerts/alerts.addon.d.ts +81 -0
- package/dist/builtins/alerts/alerts.addon.js +601 -0
- package/dist/builtins/alerts/alerts.addon.mjs +595 -0
- package/dist/builtins/alerts/index.d.ts +1 -0
- package/dist/builtins/alerts/index.js +4 -0
- package/dist/builtins/alerts/index.mjs +2 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +147 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2229 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2220 -0
- package/dist/builtins/backup-orchestrator/cron-helpers.d.ts +23 -0
- package/dist/builtins/backup-orchestrator/destination-policy.d.ts +72 -0
- package/dist/builtins/backup-orchestrator/download-helpers.d.ts +12 -0
- package/dist/builtins/backup-orchestrator/index.d.ts +2 -0
- package/dist/builtins/backup-orchestrator/index.js +8 -0
- package/dist/builtins/backup-orchestrator/index.mjs +2 -0
- package/dist/builtins/backup-orchestrator/manifest-store.d.ts +77 -0
- package/dist/builtins/console-logging/console-destination.d.ts +13 -0
- package/dist/builtins/console-logging/console-logging.addon.d.ts +25 -0
- package/dist/builtins/console-logging/index.d.ts +3 -0
- package/dist/builtins/console-logging/index.js +104 -0
- package/dist/builtins/console-logging/index.mjs +95 -0
- package/dist/builtins/device-manager/device-config-contribution.d.ts +32 -0
- package/dist/builtins/device-manager/device-event-propagator.d.ts +26 -0
- package/dist/builtins/device-manager/device-link-overlay.d.ts +23 -0
- package/dist/builtins/device-manager/device-link-resolver.d.ts +15 -0
- package/dist/builtins/device-manager/device-manager.addon.d.ts +452 -0
- package/dist/builtins/device-manager/device-manager.addon.js +3299 -0
- package/dist/builtins/device-manager/device-manager.addon.mjs +3292 -0
- package/dist/builtins/device-manager/index.d.ts +2 -0
- package/dist/builtins/device-manager/index.js +8 -0
- package/dist/builtins/device-manager/index.mjs +2 -0
- package/dist/builtins/hub-forwarder/hub-forwarder-destination.d.ts +44 -0
- package/dist/builtins/hub-forwarder/hub-forwarder.addon.d.ts +15 -0
- package/dist/builtins/hub-forwarder/index.d.ts +3 -0
- package/dist/builtins/hub-forwarder/index.js +154 -0
- package/dist/builtins/hub-forwarder/index.mjs +145 -0
- package/dist/builtins/local-auth/auth-schema.d.ts +26 -0
- package/dist/builtins/local-auth/index.d.ts +1 -0
- package/dist/builtins/local-auth/index.js +4 -0
- package/dist/builtins/local-auth/index.mjs +2 -0
- package/dist/builtins/local-auth/local-auth.addon.d.ts +18 -0
- package/dist/builtins/local-auth/local-auth.addon.js +8094 -0
- package/dist/builtins/local-auth/local-auth.addon.mjs +8063 -0
- package/dist/builtins/local-auth/oauth-grants.d.ts +45 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts +50 -0
- package/dist/builtins/local-network/index.d.ts +2 -0
- package/dist/builtins/local-network/index.js +10 -0
- package/dist/builtins/local-network/index.mjs +2 -0
- package/dist/builtins/local-network/local-network.addon.d.ts +150 -0
- package/dist/builtins/local-network/local-network.addon.js +489 -0
- package/dist/builtins/local-network/local-network.addon.mjs +477 -0
- package/dist/builtins/native-metrics/index.d.ts +2 -0
- package/dist/builtins/native-metrics/native-metrics-provider.d.ts +48 -0
- package/dist/builtins/native-metrics/native-metrics.addon.d.ts +73 -0
- package/dist/builtins/native-metrics/native-metrics.addon.js +922 -0
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +914 -0
- package/dist/builtins/platform-probe/hardware-decode-accel-probe.d.ts +37 -0
- package/dist/builtins/platform-probe/hardware-encoder-probe.d.ts +13 -0
- package/dist/builtins/platform-probe/index.d.ts +22 -0
- package/dist/builtins/platform-probe/index.js +834 -0
- package/dist/builtins/platform-probe/index.mjs +822 -0
- package/dist/builtins/platform-probe/inference-config-resolver.d.ts +29 -0
- package/dist/builtins/platform-probe/intel-accelerators.d.ts +11 -0
- package/dist/builtins/platform-probe/platform-scorer.d.ts +30 -0
- package/dist/builtins/platform-probe/runtime-packages.d.ts +6 -0
- package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts +96 -0
- package/dist/builtins/remote-access-orchestrator/index.d.ts +1 -0
- package/dist/builtins/remote-access-orchestrator/index.js +8 -0
- package/dist/builtins/remote-access-orchestrator/index.mjs +2 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts +40 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +214 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +208 -0
- package/dist/builtins/shared/settle-sources.d.ts +22 -0
- package/dist/builtins/snapshot/index.d.ts +2 -0
- package/dist/builtins/snapshot/index.js +494 -0
- package/dist/builtins/snapshot/index.mjs +488 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +120 -0
- package/dist/builtins/sqlite-storage/config-store.d.ts +8 -0
- package/dist/builtins/sqlite-storage/device-store.d.ts +23 -0
- package/dist/builtins/sqlite-storage/filesystem-browse-provider.d.ts +25 -0
- package/dist/builtins/sqlite-storage/filesystem-storage-provider.d.ts +83 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.d.ts +32 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +396 -0
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +388 -0
- package/dist/builtins/sqlite-storage/index.d.ts +8 -0
- package/dist/builtins/sqlite-storage/index.js +62 -0
- package/dist/builtins/sqlite-storage/index.mjs +49 -0
- package/dist/builtins/sqlite-storage/integration-registry.d.ts +27 -0
- package/dist/builtins/sqlite-storage/path-guard.d.ts +4 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +102 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +14 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +644 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +636 -0
- package/dist/builtins/storage-orchestrator/index.d.ts +6 -0
- package/dist/builtins/storage-orchestrator/index.js +10 -0
- package/dist/builtins/storage-orchestrator/index.mjs +2 -0
- package/dist/builtins/storage-orchestrator/location-store.d.ts +49 -0
- package/dist/builtins/storage-orchestrator/provider-discovery.d.ts +10 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +103 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1138 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1128 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +236 -0
- package/dist/builtins/storage-orchestrator/storage-pressure-manager.d.ts +38 -0
- package/dist/builtins/system-backup/system-backup.service.d.ts +137 -0
- package/dist/builtins/system-config/index.d.ts +1 -0
- package/dist/builtins/system-config/index.js +8 -0
- package/dist/builtins/system-config/index.mjs +2 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +10 -0
- package/dist/builtins/system-config/system-config.addon.js +232 -0
- package/dist/builtins/system-config/system-config.addon.mjs +226 -0
- package/dist/builtins/winston-logging/index.d.ts +3 -0
- package/dist/builtins/winston-logging/index.js +156 -0
- package/dist/builtins/winston-logging/index.mjs +144 -0
- package/dist/builtins/winston-logging/winston-destination.d.ts +21 -0
- package/dist/builtins/winston-logging/winston-logging.addon.d.ts +19 -0
- package/dist/chunk-CNf5ZN-e.mjs +37 -0
- package/dist/chunk-Cek0wNdY.js +64 -0
- package/dist/download/model-download-service.d.ts +41 -0
- package/dist/download/model-downloader.d.ts +31 -0
- package/dist/events/event-bus.d.ts +10 -0
- package/dist/events/system-event-bus.d.ts +14 -0
- package/dist/feature/feature-manager.d.ts +11 -0
- package/dist/formatter-B7qW8bPJ.mjs +162 -0
- package/dist/formatter-DqAKDlvN.js +167 -0
- package/dist/http/authenticated-file-server.d.ts +53 -0
- package/dist/http/data-plane-registry.d.ts +23 -0
- package/dist/http/file-data-plane.d.ts +10 -0
- package/dist/http/reverse-proxy.d.ts +15 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +93485 -0
- package/dist/index.mjs +93179 -0
- package/dist/intel-accelerators-Gg0P5mnl.js +20 -0
- package/dist/intel-accelerators-hGgpZ0pX.mjs +19 -0
- package/dist/kernel/addon-class-resolver.d.ts +4 -0
- package/dist/kernel/addon-engine-manager.d.ts +22 -0
- package/dist/kernel/addon-health-monitor.d.ts +154 -0
- package/dist/kernel/addon-installer.d.ts +208 -0
- package/dist/kernel/addon-loader.d.ts +106 -0
- package/dist/kernel/addon-manifest.d.ts +77 -0
- package/dist/kernel/capability-handle.d.ts +46 -0
- package/dist/kernel/capability-registry.d.ts +412 -0
- package/dist/kernel/config-manager.d.ts +212 -0
- package/dist/kernel/config-schema.d.ts +93 -0
- package/dist/kernel/custom-action-registry.d.ts +23 -0
- package/dist/kernel/deps/addon-deps-manager.d.ts +19 -0
- package/dist/kernel/deps/manifest-native-deps.d.ts +25 -0
- package/dist/kernel/deps/manifest-python-deps.d.ts +20 -0
- package/dist/kernel/device-registry.d.ts +29 -0
- package/dist/kernel/fs-utils.d.ts +41 -0
- package/dist/kernel/hwaccel/hwaccel-resolver.d.ts +19 -0
- package/dist/kernel/hwaccel/hwaccel-service.d.ts +4 -0
- package/dist/kernel/index.d.ts +74 -0
- package/dist/kernel/infra-capabilities.d.ts +13 -0
- package/dist/kernel/moleculer/addon-context-factory.d.ts +91 -0
- package/dist/kernel/moleculer/addon-data-plane-facility.d.ts +19 -0
- package/dist/kernel/moleculer/addon-runner.d.ts +1 -0
- package/dist/kernel/moleculer/addon-service-factory.d.ts +50 -0
- package/dist/kernel/moleculer/broker-factory.d.ts +50 -0
- package/dist/kernel/moleculer/cap-usage-registry.d.ts +46 -0
- package/dist/kernel/moleculer/capabilities-access.d.ts +21 -0
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +46 -0
- package/dist/kernel/moleculer/child-cap-dispatch.d.ts +20 -0
- package/dist/kernel/moleculer/cluster-secret.d.ts +15 -0
- package/dist/kernel/moleculer/core-cap-service.d.ts +50 -0
- package/dist/kernel/moleculer/crash-supervisor.d.ts +50 -0
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +79 -0
- package/dist/kernel/moleculer/event-bus-core.d.ts +53 -0
- package/dist/kernel/moleculer/event-bus.d.ts +53 -0
- package/dist/kernel/moleculer/hub-log-forwarder.d.ts +36 -0
- package/dist/kernel/moleculer/hub-service.d.ts +35 -0
- package/dist/kernel/moleculer/node-registry.d.ts +126 -0
- package/dist/kernel/moleculer/process-context.d.ts +4 -0
- package/dist/kernel/moleculer/process-service.d.ts +72 -0
- package/dist/kernel/moleculer/provider-registry.d.ts +28 -0
- package/dist/kernel/moleculer/readiness-context.d.ts +62 -0
- package/dist/kernel/moleculer/readiness-service.d.ts +7 -0
- package/dist/kernel/moleculer/register-node-client.d.ts +35 -0
- package/dist/kernel/moleculer/remote-logger.d.ts +43 -0
- package/dist/kernel/moleculer/resilient-cap-call.d.ts +28 -0
- package/dist/kernel/moleculer/stream-probe-service.d.ts +9 -0
- package/dist/kernel/moleculer/trpc-links.d.ts +189 -0
- package/dist/kernel/moleculer/typed-array-serde.d.ts +25 -0
- package/dist/kernel/moleculer/worker-device-restore.d.ts +10 -0
- package/dist/kernel/provider-kind-drift.d.ts +12 -0
- package/dist/kernel/restart-coordinator.d.ts +90 -0
- package/dist/kernel/storage-location-registry.d.ts +40 -0
- package/dist/kernel/transport/cap-action-name.d.ts +100 -0
- package/dist/kernel/transport/cap-route-resolver.d.ts +148 -0
- package/dist/kernel/transport/cap-route.d.ts +148 -0
- package/dist/kernel/transport/child-cap-protocol.d.ts +136 -0
- package/dist/kernel/transport/create-local-transport.d.ts +7 -0
- package/dist/kernel/transport/frame-codec.d.ts +7 -0
- package/dist/kernel/transport/index.d.ts +27 -0
- package/dist/kernel/transport/local-child-client.d.ts +136 -0
- package/dist/kernel/transport/local-child-registry.d.ts +179 -0
- package/dist/kernel/transport/local-endpoint-path.d.ts +6 -0
- package/dist/kernel/transport/local-transport.d.ts +46 -0
- package/dist/kernel/transport/parent-unowned-call.d.ts +75 -0
- package/dist/kernel/transport/socket-channel.d.ts +27 -0
- package/dist/kernel/transport/uds-event-bridge.d.ts +36 -0
- package/dist/kernel/transport/uds-event-bus.d.ts +22 -0
- package/dist/kernel/transport/uds-local-transport.d.ts +18 -0
- package/dist/kernel/transport/uds-log-ingest.d.ts +28 -0
- package/dist/kernel/transport/uds-logger.d.ts +44 -0
- package/dist/kernel/utils/ring-buffer.d.ts +15 -0
- package/dist/kernel/workspace-detect.d.ts +9 -0
- package/dist/lifecycle/lifecycle-state-machine.d.ts +28 -0
- package/dist/logging/formatter.d.ts +30 -0
- package/dist/logging/log-manager.d.ts +54 -0
- package/dist/logging/log-ring-buffer.d.ts +47 -0
- package/dist/logging/partitioned-log-buffer.d.ts +35 -0
- package/dist/logging/scoped-logger.d.ts +17 -0
- package/dist/main-DNnMW7Z2.js +9983 -0
- package/dist/main-rtjOwPBR.mjs +9976 -0
- package/dist/manifest-python-deps-D1DbAQEv.js +6724 -0
- package/dist/manifest-python-deps-DZsKTbs1.mjs +6315 -0
- package/dist/network/network-quality.d.ts +11 -0
- package/dist/notification/notification-service.d.ts +37 -0
- package/dist/notification/toast-service.d.ts +22 -0
- package/dist/pipeline/engine-manager-resolver.d.ts +15 -0
- package/dist/pipeline/pipeline-runner.d.ts +8 -0
- package/dist/pipeline/pipeline-validator.d.ts +13 -0
- package/dist/process/resource-monitor.d.ts +11 -0
- package/dist/python/python-env-manager.d.ts +12 -0
- package/dist/repl/interfaces.d.ts +31 -0
- package/dist/repl/repl-engine.d.ts +8 -0
- package/dist/resource-monitor-ClDGFyf6.mjs +57 -0
- package/dist/resource-monitor-IIEanuJt.js +74 -0
- package/dist/settle-sources-Bhsy57y-.js +38 -0
- package/dist/settle-sources-CDtNC8ub.mjs +33 -0
- package/dist/storage/fs-storage-backend.d.ts +40 -0
- package/dist/storage/storage-location-manager.d.ts +23 -0
- package/dist/storage/storage-manager.d.ts +83 -0
- package/dist/tar-BgAEMRBR.js +5434 -0
- package/dist/tar-ByMOPNM0.mjs +5429 -0
- package/dist/tls/cert-manager.d.ts +26 -0
- package/dist/tls/index.d.ts +1 -0
- package/package.json +343 -0
|
@@ -0,0 +1,3292 @@
|
|
|
1
|
+
import { canonicalDeviceFingerprint } from "@camstack/types/node";
|
|
2
|
+
import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/builtins/device-manager/device-event-propagator.ts
|
|
6
|
+
/**
|
|
7
|
+
* Walks the parent chain for every device-sourced event and re-emits a
|
|
8
|
+
* copy on each ancestor scope with `via[]` populated.
|
|
9
|
+
*
|
|
10
|
+
* Design goals:
|
|
11
|
+
* - Transparent: drivers emit once on their own device scope; the
|
|
12
|
+
* framework handles fan-out. Zero provider boilerplate.
|
|
13
|
+
* - Anti-loop: events that already carry `via[]` are skipped (we only
|
|
14
|
+
* propagate ORIGINAL emissions).
|
|
15
|
+
* - Anti-cycle: the parent chain is bounded — if the device registry
|
|
16
|
+
* is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
|
|
17
|
+
* and logs a warning.
|
|
18
|
+
* - Lazy: parent chain is resolved on-demand per event (no cached
|
|
19
|
+
* topology). The lookup is O(depth) which is ≤2 in practice.
|
|
20
|
+
*
|
|
21
|
+
* `via` contract (from SystemEvent.via JSDoc):
|
|
22
|
+
* - `via[0]` is the originating source (the device that produced the
|
|
23
|
+
* event). Subsequent entries walk up the parent chain.
|
|
24
|
+
* - On the re-emission, `source` is the ancestor at that level and
|
|
25
|
+
* `via[0..i]` is the prefix of the chain up to and including the
|
|
26
|
+
* first N ancestors below the current one.
|
|
27
|
+
*
|
|
28
|
+
* Example (grandchild → parent → grandparent):
|
|
29
|
+
* Original: { source: {id: 7}, data: {...}, via: undefined }
|
|
30
|
+
* Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
|
|
31
|
+
* Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
|
|
32
|
+
*
|
|
33
|
+
* A consumer listening at `source.id === 1` receives re-emit 2 (with
|
|
34
|
+
* `via` showing the chain). A consumer listening at `source.id === 7`
|
|
35
|
+
* with `via === undefined` receives the original only.
|
|
36
|
+
*/
|
|
37
|
+
/** Bounded walk — paranoia against corrupt device registries with cycles. */
|
|
38
|
+
var MAX_CHAIN_DEPTH = 16;
|
|
39
|
+
var DeviceEventPropagator = class {
|
|
40
|
+
opts;
|
|
41
|
+
unsubscribe = null;
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
this.opts = opts;
|
|
44
|
+
}
|
|
45
|
+
start() {
|
|
46
|
+
if (this.unsubscribe) return;
|
|
47
|
+
const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
|
|
48
|
+
this.unsubscribe = unsub;
|
|
49
|
+
}
|
|
50
|
+
stop() {
|
|
51
|
+
if (!this.unsubscribe) return;
|
|
52
|
+
this.unsubscribe();
|
|
53
|
+
this.unsubscribe = null;
|
|
54
|
+
}
|
|
55
|
+
/** Exposed for tests — lets them inject events without the full bus. */
|
|
56
|
+
handle(ev) {
|
|
57
|
+
if (ev.via !== void 0) return;
|
|
58
|
+
if (ev.source.type !== "device") return;
|
|
59
|
+
const rawId = ev.source.id;
|
|
60
|
+
const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
|
|
61
|
+
if (!Number.isFinite(deviceId)) return;
|
|
62
|
+
const chain = this.resolveParentChain(deviceId);
|
|
63
|
+
if (chain.length === 0) return;
|
|
64
|
+
const via = [ev.source];
|
|
65
|
+
for (const ancestorId of chain) {
|
|
66
|
+
const reEmission = {
|
|
67
|
+
...ev,
|
|
68
|
+
source: {
|
|
69
|
+
type: "device",
|
|
70
|
+
id: ancestorId
|
|
71
|
+
},
|
|
72
|
+
via: [...via]
|
|
73
|
+
};
|
|
74
|
+
this.opts.eventBus.emit(reEmission);
|
|
75
|
+
via.push({
|
|
76
|
+
type: "device",
|
|
77
|
+
id: ancestorId
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
resolveParentChain(deviceId) {
|
|
82
|
+
const chain = [];
|
|
83
|
+
const seen = new Set([deviceId]);
|
|
84
|
+
let current = this.opts.getParentOf(deviceId);
|
|
85
|
+
while (current != null) {
|
|
86
|
+
if (seen.has(current)) {
|
|
87
|
+
this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
|
|
88
|
+
tags: { deviceId },
|
|
89
|
+
meta: {
|
|
90
|
+
cycleAt: current,
|
|
91
|
+
chainSoFar: [...chain]
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return chain;
|
|
95
|
+
}
|
|
96
|
+
seen.add(current);
|
|
97
|
+
chain.push(current);
|
|
98
|
+
if (chain.length >= MAX_CHAIN_DEPTH) {
|
|
99
|
+
this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
|
|
100
|
+
tags: { deviceId },
|
|
101
|
+
meta: {
|
|
102
|
+
depth: chain.length,
|
|
103
|
+
max: MAX_CHAIN_DEPTH
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
current = this.opts.getParentOf(current);
|
|
109
|
+
}
|
|
110
|
+
return chain;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Build the device-detail form section for a `derived-form` device-config
|
|
115
|
+
* cap. Returns null when the camera exposes no configurable property.
|
|
116
|
+
*/
|
|
117
|
+
function deriveFormContribution(builderId, options, status) {
|
|
118
|
+
if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
|
|
119
|
+
const schema = buildStreamParamsConfigSchema(options, status ?? null);
|
|
120
|
+
if (!schema) return null;
|
|
121
|
+
return { sections: schema.sections.map((s) => ({
|
|
122
|
+
id: s.id,
|
|
123
|
+
title: s.title,
|
|
124
|
+
...s.tab !== void 0 ? { tab: s.tab } : {},
|
|
125
|
+
...s.order !== void 0 ? { order: s.order } : {},
|
|
126
|
+
...s.description !== void 0 ? { description: s.description } : {},
|
|
127
|
+
...s.columns !== void 0 ? { columns: s.columns } : {},
|
|
128
|
+
fields: [...s.fields]
|
|
129
|
+
})) };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Route a flat form patch back through the cap's per-profile setter.
|
|
133
|
+
*/
|
|
134
|
+
async function applyDerivedFormPatch(builderId, patch, setProfile) {
|
|
135
|
+
if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
|
|
136
|
+
for (const meta of STREAM_PROFILE_META) {
|
|
137
|
+
const profilePatch = parseStreamParamsFormPatch(patch, meta.prefix);
|
|
138
|
+
if (profilePatch) await setProfile(meta.profile, profilePatch);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/builtins/device-manager/device-link-resolver.ts
|
|
143
|
+
/** Returns true when `x` is a non-null, non-array plain object. */
|
|
144
|
+
function isRecord$1(x) {
|
|
145
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
146
|
+
}
|
|
147
|
+
/** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
|
|
148
|
+
* concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
|
|
149
|
+
* is a true `instanceof` guard rather than a cast. */
|
|
150
|
+
function asZodType(schema) {
|
|
151
|
+
return schema instanceof z.ZodType ? schema : null;
|
|
152
|
+
}
|
|
153
|
+
/** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner
|
|
154
|
+
* type. This lets the repair logic recognise a `TankStatus.nullable()` field
|
|
155
|
+
* as a ZodObject so it can fill in missing nullable keys. */
|
|
156
|
+
function unwrapSchema(schema) {
|
|
157
|
+
if (schema instanceof z.ZodNullable || schema instanceof z.ZodOptional || schema instanceof z.ZodDefault) {
|
|
158
|
+
const inner = asZodType(schema.unwrap());
|
|
159
|
+
return inner ? unwrapSchema(inner) : schema;
|
|
160
|
+
}
|
|
161
|
+
return schema;
|
|
162
|
+
}
|
|
163
|
+
/** For a ZodObject schema, ensure every nullable key present under `value` is
|
|
164
|
+
* filled — a missing key whose field accepts `null` is set to `null`. Generic
|
|
165
|
+
* repair for the common "set one leaf of a previously-null structured field"
|
|
166
|
+
* case (e.g. TankStatus.level). Recurses into nested object fields. */
|
|
167
|
+
function fillNullableDefaults(schema, value) {
|
|
168
|
+
const inner = unwrapSchema(schema);
|
|
169
|
+
if (!(inner instanceof z.ZodObject) || !isRecord$1(value)) return value;
|
|
170
|
+
const shape = inner.shape;
|
|
171
|
+
const out = { ...value };
|
|
172
|
+
for (const [key, field] of Object.entries(shape)) if (out[key] === void 0) {
|
|
173
|
+
if (field.safeParse(null).success) out[key] = null;
|
|
174
|
+
} else out[key] = fillNullableDefaults(field, out[key]);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
179
|
+
* result against the target cap's `statusSchema`. On validation failure the
|
|
180
|
+
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
181
|
+
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
182
|
+
*/
|
|
183
|
+
function mergeLinkedStatus(base, resolved, statusSchema) {
|
|
184
|
+
let draft = base;
|
|
185
|
+
let touched = false;
|
|
186
|
+
for (const { link, sourceValue } of resolved) {
|
|
187
|
+
if (sourceValue === void 0) continue;
|
|
188
|
+
draft = setByPath(draft, link.target.fieldPath, sourceValue);
|
|
189
|
+
touched = true;
|
|
190
|
+
}
|
|
191
|
+
if (!touched) return base;
|
|
192
|
+
if (!statusSchema) return draft;
|
|
193
|
+
const repaired = fillNullableDefaults(statusSchema, draft);
|
|
194
|
+
const parsed = statusSchema.safeParse(repaired);
|
|
195
|
+
if (!parsed.success) return base;
|
|
196
|
+
return isRecord$1(parsed.data) ? parsed.data : base;
|
|
197
|
+
}
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/builtins/device-manager/device-link-overlay.ts
|
|
200
|
+
/** Build both lookup maps from resolved link entries. Pure — order-preserving. */
|
|
201
|
+
function buildLinkIndexes(entries) {
|
|
202
|
+
const targets = /* @__PURE__ */ new Map();
|
|
203
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
204
|
+
for (const { targetDeviceId, link, sourceDeviceId } of entries) {
|
|
205
|
+
const tKey = `${targetDeviceId}:${link.target.cap}`;
|
|
206
|
+
const tList = targets.get(tKey) ?? [];
|
|
207
|
+
tList.push({
|
|
208
|
+
link,
|
|
209
|
+
sourceDeviceId
|
|
210
|
+
});
|
|
211
|
+
targets.set(tKey, tList);
|
|
212
|
+
const sKey = `${sourceDeviceId}:${link.source.cap}`;
|
|
213
|
+
const sList = dependents.get(sKey) ?? [];
|
|
214
|
+
sList.push({
|
|
215
|
+
targetDeviceId,
|
|
216
|
+
targetCap: link.target.cap
|
|
217
|
+
});
|
|
218
|
+
dependents.set(sKey, sList);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
targets,
|
|
222
|
+
dependents
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
227
|
+
/**
|
|
228
|
+
* Device Manager addon — hub-side singleton that unifies device persistence,
|
|
229
|
+
* live registry queries, and all management operations into a single
|
|
230
|
+
* tRPC-routable capability.
|
|
231
|
+
*
|
|
232
|
+
* Persistence strategy: all device data is stored via `ctx.settings`, the same
|
|
233
|
+
* settings API every other addon uses. No raw SQLite access.
|
|
234
|
+
*
|
|
235
|
+
* Addon store layout:
|
|
236
|
+
* deviceIndex → Record<addonId, stableId[]> (which devices exist per addon)
|
|
237
|
+
* deviceMeta → Record<"addonId:stableId", DeviceMeta> (type, name, parentDeviceId, id)
|
|
238
|
+
*
|
|
239
|
+
* Device store (per-device config):
|
|
240
|
+
* readDeviceStore(numericDeviceId) → config blob
|
|
241
|
+
* writeDeviceStore(numericDeviceId, patch)
|
|
242
|
+
*
|
|
243
|
+
* Live registry: resolved from the kernel capability registry after Phase 2.
|
|
244
|
+
* This gives direct access to in-memory IDevice instances registered by provider addons.
|
|
245
|
+
* The DeviceManagerAddon is the single owner of the live device operations API.
|
|
246
|
+
*
|
|
247
|
+
* Replaces:
|
|
248
|
+
* - `device-persistence` capability (absorbed here)
|
|
249
|
+
* - live operations previously served by `device-management.router.ts`
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* Return true when `err` is a transient Moleculer error that is worth
|
|
253
|
+
* retrying — specifically any `MoleculerRetryableError` subclass
|
|
254
|
+
* (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
|
|
255
|
+
* RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
|
|
256
|
+
*
|
|
257
|
+
* Falls back to a message-substring check for serialised errors that arrive
|
|
258
|
+
* across the Moleculer transport as plain objects rather than real instances.
|
|
259
|
+
*/
|
|
260
|
+
function isTransientMoleculerError(err) {
|
|
261
|
+
if (err !== null && typeof err === "object") {
|
|
262
|
+
const e = err;
|
|
263
|
+
if (e["retryable"] === true) return true;
|
|
264
|
+
const code = typeof e["code"] === "string" ? e["code"] : "";
|
|
265
|
+
if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
|
|
266
|
+
}
|
|
267
|
+
if (err instanceof Error) {
|
|
268
|
+
const msg = err.message;
|
|
269
|
+
if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
function shallowEqual(a, b) {
|
|
274
|
+
const ak = Object.keys(a);
|
|
275
|
+
const bk = Object.keys(b);
|
|
276
|
+
if (ak.length !== bk.length) return false;
|
|
277
|
+
for (const k of ak) if (a[k] !== b[k]) return false;
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
function deviceKey(addonId, stableId) {
|
|
281
|
+
return `${addonId}:${stableId}`;
|
|
282
|
+
}
|
|
283
|
+
/** Returns true when `x` is a non-null, non-array plain object. */
|
|
284
|
+
function isRecord(x) {
|
|
285
|
+
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
286
|
+
}
|
|
287
|
+
function isCameraDevice(device) {
|
|
288
|
+
return "getStreamSources" in device && typeof device.getStreamSources === "function";
|
|
289
|
+
}
|
|
290
|
+
var DEVICE_FEATURE_VALUES = new Set(Object.values(DeviceFeature));
|
|
291
|
+
/**
|
|
292
|
+
* Validate persisted feature strings against the `DeviceFeature` enum
|
|
293
|
+
* — workers serialise the live `device.features` array (so every entry
|
|
294
|
+
* is a valid enum value at write time) but the persisted blob is loose
|
|
295
|
+
* `string[]` on the wire. The narrow keeps unknown values out of the
|
|
296
|
+
* `getDevice` response without losing the enum-typed contract.
|
|
297
|
+
*/
|
|
298
|
+
function persistedFeatures(features) {
|
|
299
|
+
if (!features) return [];
|
|
300
|
+
const out = [];
|
|
301
|
+
for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Build an identity-only `SourceInfo` from the persisted device config blob.
|
|
306
|
+
*
|
|
307
|
+
* Forked-worker accessory children (e.g. HA sensor entities) persist
|
|
308
|
+
* `entityId` and `system` in their config blob at spawn time. The hub has no
|
|
309
|
+
* live `IDevice` instance for these devices, so the persisted-fallback paths
|
|
310
|
+
* in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
|
|
311
|
+
* `SourceInfo` from the config so dispatch routing keeps working.
|
|
312
|
+
*
|
|
313
|
+
* Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
|
|
314
|
+
* and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
|
|
315
|
+
* present) are projected — purely identity, never rendering hints.
|
|
316
|
+
*
|
|
317
|
+
* Returns `undefined` when no identity anchor is resolvable (pure identity
|
|
318
|
+
* devices like cameras/hubs that don't carry `entityId`/`system` in their
|
|
319
|
+
* config blob) — the hub synthetic fallback applies in that case.
|
|
320
|
+
*/
|
|
321
|
+
function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
|
|
322
|
+
const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
|
|
323
|
+
const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
|
|
324
|
+
if (id === void 0 && system === void 0) return void 0;
|
|
325
|
+
const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
|
|
326
|
+
return {
|
|
327
|
+
id: id ?? stableId,
|
|
328
|
+
system: system ?? addonId,
|
|
329
|
+
...uniqueId !== void 0 ? { uniqueId } : {}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
var DEVICE_ROLE_VALUES = new Set(Object.values(DeviceRole));
|
|
333
|
+
/** Type guard: a string is a known `DeviceRole` enum member. */
|
|
334
|
+
function isDeviceRole(value) {
|
|
335
|
+
return DEVICE_ROLE_VALUES.has(value);
|
|
336
|
+
}
|
|
337
|
+
/** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
|
|
338
|
+
* Unknown / null values resolve to `null` so a stale or unrecognised role
|
|
339
|
+
* never leaks an off-enum string onto the wire shape. */
|
|
340
|
+
function toDeviceRole(value) {
|
|
341
|
+
return value != null && isDeviceRole(value) ? value : null;
|
|
342
|
+
}
|
|
343
|
+
function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
|
|
344
|
+
const configValues = {};
|
|
345
|
+
for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
|
|
346
|
+
const name = metaRow?.name ?? device.name;
|
|
347
|
+
const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
|
|
348
|
+
const disabled = metaRow?.disabled ?? device.disabled;
|
|
349
|
+
const probeSlice = device.runtimeState?.getCapState("feature-probe");
|
|
350
|
+
const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
|
|
351
|
+
return {
|
|
352
|
+
id: device.id,
|
|
353
|
+
stableId: device.stableId,
|
|
354
|
+
addonId,
|
|
355
|
+
type: device.type,
|
|
356
|
+
name,
|
|
357
|
+
location,
|
|
358
|
+
disabled,
|
|
359
|
+
parentDeviceId: device.parentDeviceId,
|
|
360
|
+
role: device.role ?? null,
|
|
361
|
+
online: device.online,
|
|
362
|
+
probed,
|
|
363
|
+
features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
|
|
364
|
+
isCamera: isCameraDevice(device),
|
|
365
|
+
config: configValues,
|
|
366
|
+
metadata,
|
|
367
|
+
...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
|
|
368
|
+
...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
|
|
369
|
+
...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
|
|
370
|
+
...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
|
|
371
|
+
...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function resolveDeviceById(registry, deviceId) {
|
|
375
|
+
const device = registry.getById(deviceId);
|
|
376
|
+
if (!device) return null;
|
|
377
|
+
const addonId = registry.getAddonId(deviceId);
|
|
378
|
+
if (!addonId) return null;
|
|
379
|
+
return {
|
|
380
|
+
addonId,
|
|
381
|
+
device
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Walk the sections/fields of a contribution and inject `writerCapName` +
|
|
386
|
+
* `writerAddonId` + `source` on each editable field. Readonly fields and
|
|
387
|
+
* structural fields (separator/info/button) pass through untouched. The
|
|
388
|
+
* aggregator is the single place that knows provenance — provider schemas
|
|
389
|
+
* stay clean, UI-bound metadata is attached once at the boundary.
|
|
390
|
+
*/
|
|
391
|
+
function tagContribution(contribution, capName, addonId, kind) {
|
|
392
|
+
const source = kind === "settings" ? "settings" : "live";
|
|
393
|
+
return {
|
|
394
|
+
...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
|
|
395
|
+
sections: contribution.sections.map((section) => ({
|
|
396
|
+
...section,
|
|
397
|
+
fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
|
|
398
|
+
}))
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function isFieldRecord(value) {
|
|
402
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
|
|
406
|
+
* field union) into the cap wire shape `ContributionShape` (mutable
|
|
407
|
+
* arrays, opaque field records). Required because the cap method z.infer
|
|
408
|
+
* uses mutable arrays — readonly arrays are not assignable to mutable
|
|
409
|
+
* even when structurally identical, so a structural copy bridges the gap
|
|
410
|
+
* without disabling the type checker.
|
|
411
|
+
*/
|
|
412
|
+
function toWireShape(input) {
|
|
413
|
+
const out = { sections: input.sections.map((s) => ({
|
|
414
|
+
id: s.id,
|
|
415
|
+
title: s.title,
|
|
416
|
+
...s.description !== void 0 ? { description: s.description } : {},
|
|
417
|
+
...s.style !== void 0 ? { style: s.style } : {},
|
|
418
|
+
...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
|
|
419
|
+
...s.columns !== void 0 ? { columns: s.columns } : {},
|
|
420
|
+
...s.tab !== void 0 ? { tab: s.tab } : {},
|
|
421
|
+
...s.location !== void 0 ? { location: s.location } : {},
|
|
422
|
+
...s.order !== void 0 ? { order: s.order } : {},
|
|
423
|
+
fields: [...s.fields]
|
|
424
|
+
})) };
|
|
425
|
+
if (input.tabs) out.tabs = [...input.tabs];
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
function tagField(field, capName, addonId, source, kind) {
|
|
429
|
+
if (!isFieldRecord(field)) return field;
|
|
430
|
+
const f = field;
|
|
431
|
+
const structuralTypes = new Set([
|
|
432
|
+
"separator",
|
|
433
|
+
"info",
|
|
434
|
+
"button"
|
|
435
|
+
]);
|
|
436
|
+
if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
|
|
437
|
+
const tagged = {
|
|
438
|
+
...f,
|
|
439
|
+
source
|
|
440
|
+
};
|
|
441
|
+
if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
|
|
442
|
+
else {
|
|
443
|
+
tagged.writerCapName = capName;
|
|
444
|
+
tagged.writerAddonId = addonId;
|
|
445
|
+
}
|
|
446
|
+
if (f.type === "group") {
|
|
447
|
+
const children = Array.isArray(f.fields) ? f.fields : [];
|
|
448
|
+
if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
|
|
449
|
+
} else if (f.type === "sub-tabs") {
|
|
450
|
+
const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
|
|
451
|
+
if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
|
|
452
|
+
if (!isFieldRecord(tab)) return tab;
|
|
453
|
+
const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
|
|
454
|
+
return {
|
|
455
|
+
...tab,
|
|
456
|
+
fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return tagged;
|
|
461
|
+
}
|
|
462
|
+
function mergeAggregates(parts) {
|
|
463
|
+
const tabDecls = /* @__PURE__ */ new Map();
|
|
464
|
+
const sections = [];
|
|
465
|
+
const seenSectionIds = /* @__PURE__ */ new Set();
|
|
466
|
+
for (const part of parts) {
|
|
467
|
+
if (part.tabs) {
|
|
468
|
+
for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
|
|
469
|
+
}
|
|
470
|
+
for (const s of part.sections) {
|
|
471
|
+
if (s.id !== void 0) {
|
|
472
|
+
if (seenSectionIds.has(s.id)) continue;
|
|
473
|
+
seenSectionIds.add(s.id);
|
|
474
|
+
}
|
|
475
|
+
sections.push(s);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const s of sections) {
|
|
479
|
+
const tabId = s.tab ?? "general";
|
|
480
|
+
if (tabDecls.has(tabId)) continue;
|
|
481
|
+
const known = WELL_KNOWN_TAB_MAP[tabId];
|
|
482
|
+
if (known) tabDecls.set(tabId, {
|
|
483
|
+
id: known.id,
|
|
484
|
+
label: known.label,
|
|
485
|
+
icon: known.icon,
|
|
486
|
+
order: known.order
|
|
487
|
+
});
|
|
488
|
+
else tabDecls.set(tabId, {
|
|
489
|
+
id: tabId,
|
|
490
|
+
label: tabId,
|
|
491
|
+
icon: "wrench",
|
|
492
|
+
order: 100
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
sections.sort((a, b) => {
|
|
496
|
+
const tabA = a.tab ?? "general";
|
|
497
|
+
const tabB = b.tab ?? "general";
|
|
498
|
+
if (tabA !== tabB) {
|
|
499
|
+
const orderA = tabDecls.get(tabA)?.order ?? 100;
|
|
500
|
+
const orderB = tabDecls.get(tabB)?.order ?? 100;
|
|
501
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
502
|
+
return tabA.localeCompare(tabB);
|
|
503
|
+
}
|
|
504
|
+
return (a.order ?? 0) - (b.order ?? 0);
|
|
505
|
+
});
|
|
506
|
+
const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
|
507
|
+
const out = { sections };
|
|
508
|
+
if (sortedTabs.length > 0) out.tabs = sortedTabs;
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
|
|
512
|
+
constructor() {
|
|
513
|
+
super({});
|
|
514
|
+
}
|
|
515
|
+
/** Shorthand for the kernel-injected capability registry. */
|
|
516
|
+
get capabilityRegistry() {
|
|
517
|
+
return this.ctx.kernel.capabilityRegistry;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Parent-chain event propagator. Started in `onInitialize` once the
|
|
521
|
+
* hub's `deviceRegistry` is available; listens to every device-sourced
|
|
522
|
+
* event and re-emits a copy on each ancestor scope with `via[]`
|
|
523
|
+
* populated. Stopped in `onShutdown`.
|
|
524
|
+
*/
|
|
525
|
+
propagator = null;
|
|
526
|
+
/**
|
|
527
|
+
* Hub-side mirror of every device's cap-keyed runtime state.
|
|
528
|
+
* Populated whenever any caller writes via `deviceState.setCapSlice`
|
|
529
|
+
* (the canonical cross-layer write entrypoint) and on first load
|
|
530
|
+
* via `loadRuntimeState`. Cross-process consumers reach the mirror
|
|
531
|
+
* through the `deviceState` cap router; per-cap event subscribers
|
|
532
|
+
* (e.g. `battery.onStatusChanged`) get the same data via
|
|
533
|
+
* cap-specific events still emitted by the owning device.
|
|
534
|
+
*
|
|
535
|
+
* Key: deviceId. Value: per-cap slice map. Empty by default —
|
|
536
|
+
* slices show up as `setCapSlice` calls trickle in.
|
|
537
|
+
*/
|
|
538
|
+
stateMirror = /* @__PURE__ */ new Map();
|
|
539
|
+
/**
|
|
540
|
+
* Per-device disk-write debouncer for runtime-state. `setCapSlice`
|
|
541
|
+
* updates the in-memory mirror synchronously and emits the change
|
|
542
|
+
* event immediately, but the disk write is coalesced — frequent
|
|
543
|
+
* back-to-back writes (motion phase transitions, battery pushes,
|
|
544
|
+
* etc.) collapse to one `writeDeviceRuntimeState` per
|
|
545
|
+
* `RUNTIME_STATE_DEBOUNCE_MS` window. `flushRuntimeStateWrites`
|
|
546
|
+
* awaits any in-flight write + scheduled flush so shutdown is
|
|
547
|
+
* lossless.
|
|
548
|
+
*/
|
|
549
|
+
runtimeStateDebounce = /* @__PURE__ */ new Map();
|
|
550
|
+
static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
|
|
551
|
+
/**
|
|
552
|
+
* Cross-process native-provider cache: deviceId (numeric) → capName → { addonId, nodeId }.
|
|
553
|
+
* Kept in sync with `DeviceBindingsChanged` push events emitted by forked
|
|
554
|
+
* workers on `ctx.registerNativeCap` / device removal. Union'd into
|
|
555
|
+
* `getBindings` so hub-side consumers see every native cap regardless of
|
|
556
|
+
* which process owns the IDevice.
|
|
557
|
+
*
|
|
558
|
+
* No persistence — entries re-populate when the worker re-handshakes or
|
|
559
|
+
* re-emits its native-cap registrations after restart. Entries that were
|
|
560
|
+
* lost in the Moleculer transport handshake window are recovered lazily:
|
|
561
|
+
* `resolveNativeCapOwnerSync` and `getBindings` fall through to
|
|
562
|
+
* `ctx.kernel.listClusterNativeCaps()` (the handshake-fed
|
|
563
|
+
* `HubNodeRegistry`) when the push-based cache misses.
|
|
564
|
+
*
|
|
565
|
+
* The previous pull-based recovery (`syncWorkerNativeCaps`, driven by
|
|
566
|
+
* `$node.connected` + `addon.restarted`) has been removed in Task 13 —
|
|
567
|
+
* the D3 re-handshake after device restore is the reliable replacement.
|
|
568
|
+
*/
|
|
569
|
+
remoteNativeCaps = /* @__PURE__ */ new Map();
|
|
570
|
+
/** Device ids that currently have at least one `deviceLinks` entry. O(1) gate
|
|
571
|
+
* consulted by `resolveLinkedStatus` so the cap-mount overlay is a no-op for
|
|
572
|
+
* the vast majority of devices that have no links. Rebuilt on restore. */
|
|
573
|
+
devicesWithLinks = /* @__PURE__ */ new Set();
|
|
574
|
+
/** Whether a device currently has cross-device links. */
|
|
575
|
+
devicesWithLinksHas(deviceId) {
|
|
576
|
+
return this.devicesWithLinks.has(deviceId);
|
|
577
|
+
}
|
|
578
|
+
/** `${targetDeviceId}:${targetCap}` → resolved links feeding that cap's slice. */
|
|
579
|
+
linkTargets = /* @__PURE__ */ new Map();
|
|
580
|
+
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
|
|
581
|
+
linkDependents = /* @__PURE__ */ new Map();
|
|
582
|
+
/** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
|
|
583
|
+
lastEmittedOverlay = /* @__PURE__ */ new Map();
|
|
584
|
+
/** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
|
|
585
|
+
* resolved or not — gates the `registerDevice` rebuild so only a registering
|
|
586
|
+
* device that IS a link source triggers a reindex (not every boot restore). */
|
|
587
|
+
expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
588
|
+
/** Test/diagnostic accessors. */
|
|
589
|
+
linkTargetKeys() {
|
|
590
|
+
return [...this.linkTargets.keys()];
|
|
591
|
+
}
|
|
592
|
+
linkDependentsKeys() {
|
|
593
|
+
return [...this.linkDependents.keys()];
|
|
594
|
+
}
|
|
595
|
+
/** Wait for a device-provider by addonId, returning null on timeout. */
|
|
596
|
+
async waitDeviceProvider(addonId, timeoutMs = 5e3) {
|
|
597
|
+
const provider = await this.capabilityRegistry?.waitForProvider("device-provider", addonId, timeoutMs);
|
|
598
|
+
return provider ? provider : null;
|
|
599
|
+
}
|
|
600
|
+
/** Require a device-provider by addonId — throws if not found. */
|
|
601
|
+
async requireDeviceProvider(addonId) {
|
|
602
|
+
const dp = await this.waitDeviceProvider(addonId);
|
|
603
|
+
if (!dp) throw new Error(`Device provider "${addonId}" not found or not registered`);
|
|
604
|
+
return dp;
|
|
605
|
+
}
|
|
606
|
+
/** Require a device-adoption provider by addonId — throws if not found. */
|
|
607
|
+
async requireDeviceAdoptionProvider(addonId) {
|
|
608
|
+
const provider = await this.capabilityRegistry?.waitForProvider("device-adoption", addonId, 5e3);
|
|
609
|
+
if (!provider) throw new Error(`Device-adoption provider "${addonId}" not found or not registered`);
|
|
610
|
+
return provider;
|
|
611
|
+
}
|
|
612
|
+
async readBindingsStore() {
|
|
613
|
+
return { deviceBindings: (await this.ctx.settings.readAddonStore()).deviceBindings ?? {} };
|
|
614
|
+
}
|
|
615
|
+
async writeBindingsStore(next) {
|
|
616
|
+
await this.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
|
|
617
|
+
}
|
|
618
|
+
resolveWrapperNodeId(_wrapperAddonId) {
|
|
619
|
+
return "hub";
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Reduce a provider node id to the routable form `DeviceProxy` can pin.
|
|
623
|
+
*
|
|
624
|
+
* Every addon runs in its own `addon-runner` with the composite node id
|
|
625
|
+
* `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
|
|
626
|
+
* `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
|
|
627
|
+
* an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
|
|
628
|
+
* only THROUGH its parent (the hub resolves a hub-local-uds child by
|
|
629
|
+
* cap+device; an agent forwards to its own child). `DeviceProxy` pins
|
|
630
|
+
* `entry.providerNodeId` on every cap call, so a binding entry must expose the
|
|
631
|
+
* parent node id — otherwise the explicit pin classifies as `remote-moleculer`
|
|
632
|
+
* to an unknown node → `no-provider`, which surfaces as
|
|
633
|
+
* "this camera doesn't expose …" for client-proxy-driven widget caps
|
|
634
|
+
* (motion-zones, privacy-mask). Wrappers already report the parent via
|
|
635
|
+
* `resolveWrapperNodeId`; this aligns natives with the same contract.
|
|
636
|
+
*
|
|
637
|
+
* A flat node id (a genuine standalone node with no `/`) is returned
|
|
638
|
+
* unchanged.
|
|
639
|
+
*/
|
|
640
|
+
toRoutableProviderNodeId(nodeId) {
|
|
641
|
+
const slash = nodeId.indexOf("/");
|
|
642
|
+
return slash === -1 ? nodeId : nodeId.slice(0, slash);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Resolve a remote native cap entry for a given `(capName, deviceId)` by
|
|
646
|
+
* consulting the handshake-fed `HubNodeRegistry` via
|
|
647
|
+
* `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
|
|
648
|
+
* `remoteNativeCaps` cache misses — covers the Moleculer transport
|
|
649
|
+
* handshake window where `DeviceBindingsChanged` events were lost but the
|
|
650
|
+
* D3 re-handshake (post device restore) has already populated the registry.
|
|
651
|
+
*
|
|
652
|
+
* Returns `null` when the entry is genuinely not present in the cluster
|
|
653
|
+
* view (cap not registered on any worker for that device).
|
|
654
|
+
*/
|
|
655
|
+
resolveRemoteNativeCapFromRegistry(capName, deviceId) {
|
|
656
|
+
const clusterCaps = this.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? this.ctx.kernel.listClusterNativeCaps?.();
|
|
657
|
+
if (!clusterCaps) return null;
|
|
658
|
+
for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
|
|
659
|
+
addonId: entry.addonId,
|
|
660
|
+
nodeId: entry.nodeId
|
|
661
|
+
};
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
async getBindings(input) {
|
|
665
|
+
const storeKey = String(input.deviceId);
|
|
666
|
+
const perDevice = (await this.readBindingsStore()).deviceBindings[storeKey] ?? {};
|
|
667
|
+
const entries = [];
|
|
668
|
+
const seenCaps = /* @__PURE__ */ new Set();
|
|
669
|
+
const resolveRemote = (capName) => this.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? this.resolveRemoteNativeCapFromRegistry(capName, input.deviceId);
|
|
670
|
+
for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
|
|
671
|
+
const hubLocalNative = this.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
|
|
672
|
+
const remoteNative = resolveRemote(capName);
|
|
673
|
+
const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
|
|
674
|
+
const nativeNodeId = hubLocalNative ? this.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? this.ctx.kernel.localNodeId ?? "hub";
|
|
675
|
+
if (wrapperAddonId === null && !nativeAddonId) {
|
|
676
|
+
seenCaps.add(capName);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
entries.push({
|
|
680
|
+
capName,
|
|
681
|
+
kind: wrapperAddonId ? "wrapped" : "native",
|
|
682
|
+
providerAddonId: wrapperAddonId ?? nativeAddonId,
|
|
683
|
+
providerNodeId: wrapperAddonId ? this.resolveWrapperNodeId(wrapperAddonId) : this.toRoutableProviderNodeId(nativeNodeId),
|
|
684
|
+
nativeAddonId
|
|
685
|
+
});
|
|
686
|
+
seenCaps.add(capName);
|
|
687
|
+
}
|
|
688
|
+
if (this.capabilityRegistry) for (const capName of this.capabilityRegistry.getCapsWithDefaultWrapper()) {
|
|
689
|
+
if (seenCaps.has(capName)) continue;
|
|
690
|
+
const defaultWrapperAddonId = this.capabilityRegistry.getDefaultWrapperForCap(capName);
|
|
691
|
+
if (!defaultWrapperAddonId) continue;
|
|
692
|
+
const hubLocalNative = this.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
|
|
693
|
+
const remoteNative = resolveRemote(capName);
|
|
694
|
+
const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
|
|
695
|
+
entries.push({
|
|
696
|
+
capName,
|
|
697
|
+
kind: "wrapped",
|
|
698
|
+
providerAddonId: defaultWrapperAddonId,
|
|
699
|
+
providerNodeId: this.resolveWrapperNodeId(defaultWrapperAddonId),
|
|
700
|
+
nativeAddonId
|
|
701
|
+
});
|
|
702
|
+
seenCaps.add(capName);
|
|
703
|
+
}
|
|
704
|
+
if (this.capabilityRegistry) for (const capName of this.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
|
|
705
|
+
if (seenCaps.has(capName)) continue;
|
|
706
|
+
const nativeAddonId = this.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
|
|
707
|
+
entries.push({
|
|
708
|
+
capName,
|
|
709
|
+
kind: "native",
|
|
710
|
+
providerAddonId: nativeAddonId,
|
|
711
|
+
providerNodeId: this.ctx.kernel.localNodeId ?? "hub",
|
|
712
|
+
nativeAddonId
|
|
713
|
+
});
|
|
714
|
+
seenCaps.add(capName);
|
|
715
|
+
}
|
|
716
|
+
const pushFed = this.remoteNativeCaps.get(input.deviceId);
|
|
717
|
+
if (pushFed) for (const [capName, info] of pushFed) {
|
|
718
|
+
if (seenCaps.has(capName)) continue;
|
|
719
|
+
entries.push({
|
|
720
|
+
capName,
|
|
721
|
+
kind: "native",
|
|
722
|
+
providerAddonId: info.addonId,
|
|
723
|
+
providerNodeId: this.toRoutableProviderNodeId(info.nodeId),
|
|
724
|
+
nativeAddonId: info.addonId
|
|
725
|
+
});
|
|
726
|
+
seenCaps.add(capName);
|
|
727
|
+
}
|
|
728
|
+
const clusterCaps = this.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? this.ctx.kernel.listClusterNativeCaps?.();
|
|
729
|
+
if (clusterCaps) for (const entry of clusterCaps) {
|
|
730
|
+
if (entry.deviceId !== input.deviceId) continue;
|
|
731
|
+
if (seenCaps.has(entry.capName)) continue;
|
|
732
|
+
if (!entry.addonId) continue;
|
|
733
|
+
const localNodeId = this.ctx.kernel.localNodeId ?? "hub";
|
|
734
|
+
if (entry.nodeId === localNodeId) continue;
|
|
735
|
+
entries.push({
|
|
736
|
+
capName: entry.capName,
|
|
737
|
+
kind: "native",
|
|
738
|
+
providerAddonId: entry.addonId,
|
|
739
|
+
providerNodeId: this.toRoutableProviderNodeId(entry.nodeId),
|
|
740
|
+
nativeAddonId: entry.addonId
|
|
741
|
+
});
|
|
742
|
+
seenCaps.add(entry.capName);
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
deviceId: input.deviceId,
|
|
746
|
+
entries
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Whole-fleet binding dump. Iterates every device known to the
|
|
751
|
+
* deviceRegistry and reuses the per-device `getBindings` resolver
|
|
752
|
+
* for each — same routing rules, single round-trip. Used by
|
|
753
|
+
* `SystemManager.init()` for warm-boot.
|
|
754
|
+
*
|
|
755
|
+
* Bindings change rarely (wrapper toggle, device add/remove) so
|
|
756
|
+
* clients invalidate via the existing
|
|
757
|
+
* `capability.binding-changed` event rather than re-fetching this
|
|
758
|
+
* payload periodically.
|
|
759
|
+
*/
|
|
760
|
+
async getAllBindings() {
|
|
761
|
+
const hubRegistry = this.ctx.kernel?.deviceRegistry;
|
|
762
|
+
if (!hubRegistry) return [];
|
|
763
|
+
const out = [];
|
|
764
|
+
for (const device of hubRegistry.getAll()) out.push(await this.getBindings({ deviceId: device.id }));
|
|
765
|
+
return out;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Resolve a numeric deviceId to a stableId via persisted meta.
|
|
769
|
+
* Used only by the device-identity section of the device-details
|
|
770
|
+
* aggregator (see `buildBaseDeviceSection`) to surface the stableId as
|
|
771
|
+
* a readonly display field. All runtime/registry lookups are keyed by
|
|
772
|
+
* numeric deviceId; this helper is display-only.
|
|
773
|
+
*/
|
|
774
|
+
async lookupPersistedStableId(deviceId) {
|
|
775
|
+
const meta = (await this.ctx.settings.readAddonStore()).deviceMeta ?? {};
|
|
776
|
+
for (const [key, m] of Object.entries(meta)) if (m.id === deviceId) {
|
|
777
|
+
const sep = key.indexOf(":");
|
|
778
|
+
if (sep < 0) continue;
|
|
779
|
+
return key.slice(sep + 1);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async getDeviceAggregate(deviceId, kind) {
|
|
783
|
+
const registry = this.capabilityRegistry;
|
|
784
|
+
if (!registry) {
|
|
785
|
+
this.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
|
|
789
|
+
const { entries: bindingEntries } = await this.getBindings({ deviceId });
|
|
790
|
+
const seenCaps = new Set(bindingEntries.map((e) => e.capName));
|
|
791
|
+
const results = await Promise.all(bindingEntries.map(async (entry) => {
|
|
792
|
+
const def = registry.getDefinition(entry.capName);
|
|
793
|
+
if (isDeviceConfigCap(def)) return this.deriveDeviceConfigContribution(registry, def, entry, deviceId, kind);
|
|
794
|
+
if (!def?.exposesDeviceSettings) return null;
|
|
795
|
+
const provider = registry.getProviderByAddon(entry.capName, entry.providerAddonId);
|
|
796
|
+
if (!provider) return null;
|
|
797
|
+
try {
|
|
798
|
+
const contribution = await provider[method]({ deviceId });
|
|
799
|
+
if (!contribution) return null;
|
|
800
|
+
return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
803
|
+
this.ctx.logger.warn("contribution method failed", {
|
|
804
|
+
tags: {
|
|
805
|
+
deviceId,
|
|
806
|
+
addonId: entry.providerAddonId
|
|
807
|
+
},
|
|
808
|
+
meta: {
|
|
809
|
+
capName: entry.capName,
|
|
810
|
+
method,
|
|
811
|
+
error: msg
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}));
|
|
817
|
+
const base = kind === "settings" ? await this.buildBaseDeviceSection(deviceId) : null;
|
|
818
|
+
const systemResults = await this.collectSystemDeviceContributions(deviceId, kind, seenCaps);
|
|
819
|
+
const parts = [
|
|
820
|
+
...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [],
|
|
821
|
+
...results.filter((r) => r !== null),
|
|
822
|
+
...systemResults
|
|
823
|
+
];
|
|
824
|
+
if (parts.length === 0) return null;
|
|
825
|
+
return mergeAggregates(parts);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* System-scoped per-device contributions — the deliberate exception to
|
|
829
|
+
* binding-driven aggregation (D12).
|
|
830
|
+
*
|
|
831
|
+
* A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
|
|
832
|
+
* state but is NOT bound per-device: it is a system addon, not a BaseDevice,
|
|
833
|
+
* so it never calls `registerNativeCap` and never appears in
|
|
834
|
+
* `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
|
|
835
|
+
* per-device panel never renders. Two such caps ship today:
|
|
836
|
+
* - `stream-broker` (singleton) — the RTSP-restream / pre-buffer settings
|
|
837
|
+
* tab, scoped to the cameras whose streams it serves.
|
|
838
|
+
* - `device-export` (collection) — the "Export" panel; a device can be
|
|
839
|
+
* exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
|
|
840
|
+
*
|
|
841
|
+
* D12 bans fanning a per-device call out to every same-named provider of a
|
|
842
|
+
* `scope: 'device'` cap, because vendor providers SHARE cap names
|
|
843
|
+
* (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
|
|
844
|
+
* vendor-shared — a system cap name maps to one active provider (singleton)
|
|
845
|
+
* or one declared collection — so consulting the cap's own provider(s)
|
|
846
|
+
* directly is safe. Each provider gates internally by deviceId and returns
|
|
847
|
+
* null for devices it does not own.
|
|
848
|
+
*
|
|
849
|
+
* `seenCaps` skips any system cap that already contributed via a binding so
|
|
850
|
+
* its section is never duplicated.
|
|
851
|
+
*/
|
|
852
|
+
async collectSystemDeviceContributions(deviceId, kind, seenCaps) {
|
|
853
|
+
const registry = this.capabilityRegistry;
|
|
854
|
+
if (!registry) return [];
|
|
855
|
+
const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
|
|
856
|
+
const tasks = [];
|
|
857
|
+
for (const info of registry.listCapabilities()) {
|
|
858
|
+
const def = registry.getDefinition(info.name);
|
|
859
|
+
if (!def || def.scope !== "system" || !def.exposesDeviceSettings) continue;
|
|
860
|
+
if (seenCaps.has(info.name)) continue;
|
|
861
|
+
const addonIds = def.mode === "collection" ? info.providers : info.activeProvider ? [info.activeProvider] : [];
|
|
862
|
+
for (const addonId of addonIds) tasks.push((async () => {
|
|
863
|
+
const provider = registry.getProviderByAddon(info.name, addonId);
|
|
864
|
+
if (!provider) return null;
|
|
865
|
+
try {
|
|
866
|
+
const contribution = await provider[method]({ deviceId });
|
|
867
|
+
if (!contribution) return null;
|
|
868
|
+
return tagContribution(toWireShape(contribution), info.name, addonId, kind);
|
|
869
|
+
} catch (err) {
|
|
870
|
+
this.ctx.logger.warn("system device-settings contribution skipped", {
|
|
871
|
+
tags: {
|
|
872
|
+
deviceId,
|
|
873
|
+
addonId
|
|
874
|
+
},
|
|
875
|
+
meta: {
|
|
876
|
+
capName: info.name,
|
|
877
|
+
method,
|
|
878
|
+
error: err instanceof Error ? err.message : String(err)
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
})());
|
|
884
|
+
}
|
|
885
|
+
return (await Promise.all(tasks)).filter((r) => r !== null);
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* D14: framework-derived device-config contribution.
|
|
889
|
+
*
|
|
890
|
+
* `kind === 'live'` — device-config caps contribute nothing to the live
|
|
891
|
+
* aggregate (they hold editable config, not live observables).
|
|
892
|
+
*
|
|
893
|
+
* `ui.kind === 'widget'` — emits a single structural `type:'widget'`
|
|
894
|
+
* section; the widget self-persists via the cap's own mutations.
|
|
895
|
+
*
|
|
896
|
+
* `ui.kind === 'derived-form'` — calls `getOptions`/`getStatus` on the
|
|
897
|
+
* bound provider, runs the registered pure builder, and returns the
|
|
898
|
+
* derived form sections. Returns null when the camera exposes nothing
|
|
899
|
+
* configurable or the provider is not yet registered.
|
|
900
|
+
*/
|
|
901
|
+
async deriveDeviceConfigContribution(registry, def, entry, deviceId, kind) {
|
|
902
|
+
if (kind === "live") return null;
|
|
903
|
+
const ui = def.deviceConfig.ui;
|
|
904
|
+
if (ui.kind === "widget") return tagContribution({ sections: [{
|
|
905
|
+
id: `${entry.capName}-widget`,
|
|
906
|
+
title: ui.label,
|
|
907
|
+
tab: ui.tab,
|
|
908
|
+
order: ui.order ?? 0,
|
|
909
|
+
...ui.topTab ? { location: "top-tab" } : {},
|
|
910
|
+
fields: [{
|
|
911
|
+
type: "widget",
|
|
912
|
+
key: `${entry.capName}Widget`,
|
|
913
|
+
label: ui.label,
|
|
914
|
+
widgetId: ui.widgetId
|
|
915
|
+
}]
|
|
916
|
+
}] }, entry.capName, entry.providerAddonId, kind);
|
|
917
|
+
const provider = registry.getProviderForDevice(entry.capName, deviceId);
|
|
918
|
+
if (!provider || typeof provider.getOptions !== "function") return null;
|
|
919
|
+
try {
|
|
920
|
+
const options = await provider.getOptions({ deviceId });
|
|
921
|
+
const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
|
|
922
|
+
const derived = deriveFormContribution(ui.builderId, options, status ?? null);
|
|
923
|
+
if (!derived) return null;
|
|
924
|
+
return tagContribution({
|
|
925
|
+
...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
|
|
926
|
+
sections: derived.sections.map((s) => ({
|
|
927
|
+
id: s.id,
|
|
928
|
+
title: s.title,
|
|
929
|
+
...s.tab !== void 0 ? { tab: s.tab } : {},
|
|
930
|
+
...s.order !== void 0 ? { order: s.order } : {},
|
|
931
|
+
...s.description !== void 0 ? { description: s.description } : {},
|
|
932
|
+
...s.columns !== void 0 ? { columns: s.columns } : {},
|
|
933
|
+
fields: [...s.fields]
|
|
934
|
+
}))
|
|
935
|
+
}, entry.capName, entry.providerAddonId, kind);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
938
|
+
this.ctx.logger.warn("device-config derivation failed", {
|
|
939
|
+
tags: {
|
|
940
|
+
deviceId,
|
|
941
|
+
addonId: entry.providerAddonId
|
|
942
|
+
},
|
|
943
|
+
meta: {
|
|
944
|
+
capName: entry.capName,
|
|
945
|
+
error: msg
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Build the device-manager's own contribution to the aggregator — the
|
|
953
|
+
* device identity (id, stableId, addonId, type, online) + the
|
|
954
|
+
* driver-specific config exposed by the device class via
|
|
955
|
+
* `zodEntriesToConfigUI`.
|
|
956
|
+
*
|
|
957
|
+
* Two paths, deliberately symmetric with `getSettingsSchema`:
|
|
958
|
+
*
|
|
959
|
+
* - Hub-local: device's IDevice instance lives in this process'
|
|
960
|
+
* DeviceRegistry, we read config + schema directly by reference.
|
|
961
|
+
* - Cross-process: device lives in a forked worker (RtspCamera on
|
|
962
|
+
* provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
|
|
963
|
+
* `device-ops.getSettingsSchema` native provider for a wire-
|
|
964
|
+
* serializable ConfigUISchema and merge it in under the same
|
|
965
|
+
* "Driver Config" section, so the UI sees the same shape regardless
|
|
966
|
+
* of where the IDevice physically runs.
|
|
967
|
+
*
|
|
968
|
+
* Returns `null` only when the device genuinely doesn't exist anywhere
|
|
969
|
+
* (no hub-local, no persisted ownership, no device-ops native). The
|
|
970
|
+
* aggregator falls back to contributor sections only in that case.
|
|
971
|
+
*/
|
|
972
|
+
async buildBaseDeviceSection(deviceId) {
|
|
973
|
+
const hubRegistry = this.ctx.kernel?.deviceRegistry;
|
|
974
|
+
const hubLocal = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
|
|
975
|
+
const stableId = hubLocal?.device.stableId ?? await this.lookupPersistedStableId(deviceId);
|
|
976
|
+
const nativeOwner = this.resolveNativeDeviceOwner(deviceId);
|
|
977
|
+
const addonId = hubLocal?.addonId ?? nativeOwner?.addonId ?? null;
|
|
978
|
+
if (!hubLocal && !nativeOwner) return null;
|
|
979
|
+
const sections = [{
|
|
980
|
+
id: "device-identity",
|
|
981
|
+
title: "Identity",
|
|
982
|
+
tab: "general",
|
|
983
|
+
order: 0,
|
|
984
|
+
fields: [
|
|
985
|
+
{
|
|
986
|
+
type: "text",
|
|
987
|
+
key: "_deviceId",
|
|
988
|
+
label: "Device ID",
|
|
989
|
+
readonlyField: true,
|
|
990
|
+
value: String(deviceId)
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
type: "text",
|
|
994
|
+
key: "_stableId",
|
|
995
|
+
label: "Stable ID",
|
|
996
|
+
readonlyField: true,
|
|
997
|
+
value: stableId ?? ""
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
type: "text",
|
|
1001
|
+
key: "_addonId",
|
|
1002
|
+
label: "Driver",
|
|
1003
|
+
readonlyField: true,
|
|
1004
|
+
value: addonId ?? "unknown"
|
|
1005
|
+
},
|
|
1006
|
+
...hubLocal ? [{
|
|
1007
|
+
type: "text",
|
|
1008
|
+
key: "_type",
|
|
1009
|
+
label: "Type",
|
|
1010
|
+
readonlyField: true,
|
|
1011
|
+
value: hubLocal.device.type
|
|
1012
|
+
}, {
|
|
1013
|
+
type: "text",
|
|
1014
|
+
key: "_online",
|
|
1015
|
+
label: "Online",
|
|
1016
|
+
readonlyField: true,
|
|
1017
|
+
value: hubLocal.device.online ? "yes" : "no"
|
|
1018
|
+
}] : []
|
|
1019
|
+
]
|
|
1020
|
+
}];
|
|
1021
|
+
const driverResult = await this.resolveDriverConfigSchema(deviceId, hubLocal);
|
|
1022
|
+
if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
|
|
1023
|
+
id: section.id,
|
|
1024
|
+
title: section.title,
|
|
1025
|
+
tab: section.tab ?? "general",
|
|
1026
|
+
order: section.order ?? 1,
|
|
1027
|
+
fields: [...section.fields],
|
|
1028
|
+
...section.description !== void 0 ? { description: section.description } : {},
|
|
1029
|
+
...section.columns !== void 0 ? { columns: section.columns } : {}
|
|
1030
|
+
});
|
|
1031
|
+
else if (driverResult.status === "unavailable") sections.push({
|
|
1032
|
+
id: "driver-settings-unavailable",
|
|
1033
|
+
title: "Driver Settings",
|
|
1034
|
+
tab: "general",
|
|
1035
|
+
order: 1,
|
|
1036
|
+
fields: [{
|
|
1037
|
+
type: "info",
|
|
1038
|
+
key: "driver-settings-unavailable-notice",
|
|
1039
|
+
label: "Temporarily unavailable",
|
|
1040
|
+
content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
|
|
1041
|
+
variant: "warning"
|
|
1042
|
+
}]
|
|
1043
|
+
});
|
|
1044
|
+
return {
|
|
1045
|
+
sections,
|
|
1046
|
+
...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Lookup the native owner for `device-ops` on `deviceId` — the native-cap
|
|
1051
|
+
* registry (hub-local and remote) is keyed by numeric id.
|
|
1052
|
+
*/
|
|
1053
|
+
resolveNativeDeviceOwner(deviceId) {
|
|
1054
|
+
const local = this.capabilityRegistry?.getNativeAddonId("device-ops", deviceId) ?? null;
|
|
1055
|
+
if (local) return {
|
|
1056
|
+
addonId: local,
|
|
1057
|
+
nodeId: this.ctx.kernel.localNodeId ?? "hub"
|
|
1058
|
+
};
|
|
1059
|
+
const remote = this.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? this.resolveRemoteNativeCapFromRegistry("device-ops", deviceId);
|
|
1060
|
+
return remote ? {
|
|
1061
|
+
addonId: remote.addonId,
|
|
1062
|
+
nodeId: remote.nodeId
|
|
1063
|
+
} : null;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Aggregate `status` across every registered cap for a device.
|
|
1067
|
+
*
|
|
1068
|
+
* Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
|
|
1069
|
+
* omitted), looks up a native provider per cap via the capability
|
|
1070
|
+
* registry, calls `provider.getStatus({ deviceId })`, and validates
|
|
1071
|
+
* the return against the cap's own `status.schema`. Validation
|
|
1072
|
+
* failures log a warning and yield `null` for that cap so the
|
|
1073
|
+
* overall aggregate stays usable — a single misbehaving provider
|
|
1074
|
+
* must not blank out a device's entire status view.
|
|
1075
|
+
*
|
|
1076
|
+
* Returned shape is `Record<capName, unknown | null>`; the client-
|
|
1077
|
+
* side hook tightens this to `CapStatusTypeMap` via the generated
|
|
1078
|
+
* `cap-status-types.ts`.
|
|
1079
|
+
*/
|
|
1080
|
+
async getDeviceStatusAggregate(input) {
|
|
1081
|
+
const capNames = input.caps ?? CAP_NAMES_WITH_STATUS;
|
|
1082
|
+
const registry = this.capabilityRegistry;
|
|
1083
|
+
const out = {};
|
|
1084
|
+
if (!registry) {
|
|
1085
|
+
for (const name of capNames) out[name] = null;
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
await Promise.all(capNames.map(async (capName) => {
|
|
1089
|
+
try {
|
|
1090
|
+
const def = registry.getDefinition(capName);
|
|
1091
|
+
if (!def?.status) {
|
|
1092
|
+
out[capName] = null;
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
const provider = registry.getProviderForDevice(capName, input.deviceId);
|
|
1096
|
+
if (!provider || typeof provider.getStatus !== "function") {
|
|
1097
|
+
out[capName] = null;
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const raw = await provider.getStatus({ deviceId: input.deviceId });
|
|
1101
|
+
if (raw == null) {
|
|
1102
|
+
out[capName] = null;
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
const parsed = def.status.schema.safeParse(raw);
|
|
1106
|
+
if (!parsed.success) {
|
|
1107
|
+
this.ctx.logger.warn("getDeviceStatusAggregate: provider returned invalid status, dropping", {
|
|
1108
|
+
tags: { deviceId: input.deviceId },
|
|
1109
|
+
meta: {
|
|
1110
|
+
capName,
|
|
1111
|
+
issues: parsed.error.issues.slice(0, 3)
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
out[capName] = null;
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
out[capName] = parsed.data;
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
this.ctx.logger.warn("getDeviceStatusAggregate: provider threw, dropping", {
|
|
1120
|
+
tags: { deviceId: input.deviceId },
|
|
1121
|
+
meta: {
|
|
1122
|
+
capName,
|
|
1123
|
+
error: errMsg(err)
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
out[capName] = null;
|
|
1127
|
+
}
|
|
1128
|
+
}));
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Return the driver-specific device-settings contribution. Hub-local
|
|
1133
|
+
* devices call `getSettingsUISchema()` directly; forked-worker devices
|
|
1134
|
+
* go through the `device-ops.getSettingsSchema` cap method on the
|
|
1135
|
+
* numeric-id-keyed native registry.
|
|
1136
|
+
*
|
|
1137
|
+
* Returns a discriminated result so callers can distinguish three states:
|
|
1138
|
+
* 'ok' – schema obtained successfully
|
|
1139
|
+
* 'none' – driver genuinely has no settings schema
|
|
1140
|
+
* 'unavailable' – worker was unreachable after retries (transient)
|
|
1141
|
+
*/
|
|
1142
|
+
async resolveDriverConfigSchema(deviceId, hubLocal) {
|
|
1143
|
+
if (hubLocal) {
|
|
1144
|
+
const schema = hubLocal.device.getSettingsUISchema();
|
|
1145
|
+
return schema.sections.length === 0 ? { status: "none" } : {
|
|
1146
|
+
status: "ok",
|
|
1147
|
+
schema: toWireShape(schema)
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
|
|
1151
|
+
if (!ops) return { status: "none" };
|
|
1152
|
+
const MAX_RETRIES = 2;
|
|
1153
|
+
const RETRY_DELAY_MS = 750;
|
|
1154
|
+
let lastErr;
|
|
1155
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
1156
|
+
if (attempt > 0) await sleep(RETRY_DELAY_MS);
|
|
1157
|
+
try {
|
|
1158
|
+
const schema = await ops.getSettingsSchema({ deviceId });
|
|
1159
|
+
if (!schema) return { status: "none" };
|
|
1160
|
+
const wire = schema;
|
|
1161
|
+
return wire.sections.length === 0 ? { status: "none" } : {
|
|
1162
|
+
status: "ok",
|
|
1163
|
+
schema: toWireShape(wire)
|
|
1164
|
+
};
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
lastErr = err;
|
|
1167
|
+
if (!isTransientMoleculerError(err)) break;
|
|
1168
|
+
if (attempt < MAX_RETRIES) this.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
|
|
1169
|
+
tags: { deviceId },
|
|
1170
|
+
meta: {
|
|
1171
|
+
attempt: attempt + 1,
|
|
1172
|
+
maxRetries: MAX_RETRIES,
|
|
1173
|
+
error: errMsg(err)
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const msg = errMsg(lastErr);
|
|
1179
|
+
this.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
|
|
1180
|
+
tags: { deviceId },
|
|
1181
|
+
meta: { error: msg }
|
|
1182
|
+
});
|
|
1183
|
+
return { status: "unavailable" };
|
|
1184
|
+
}
|
|
1185
|
+
async updateDeviceField(input) {
|
|
1186
|
+
if (input.writerCapName === "device-manager") {
|
|
1187
|
+
const hubRegistry = this.ctx.kernel?.deviceRegistry;
|
|
1188
|
+
const found = hubRegistry ? resolveDeviceById(hubRegistry, input.deviceId) : null;
|
|
1189
|
+
if (found) {
|
|
1190
|
+
await found.device.applySettingsPatch({ [input.key]: input.value });
|
|
1191
|
+
return { success: true };
|
|
1192
|
+
}
|
|
1193
|
+
const ops = this.capabilityRegistry?.getNativeProvider("device-ops", input.deviceId);
|
|
1194
|
+
if (!ops) throw new Error(`[device-manager] device "${input.deviceId}" not found (no hub-local entry, no device-ops native provider)`);
|
|
1195
|
+
await ops.setConfig({
|
|
1196
|
+
deviceId: input.deviceId,
|
|
1197
|
+
values: { [input.key]: input.value }
|
|
1198
|
+
});
|
|
1199
|
+
return { success: true };
|
|
1200
|
+
}
|
|
1201
|
+
const registry = this.capabilityRegistry;
|
|
1202
|
+
if (!registry) throw new Error("[device-manager] updateDeviceField requires capability registry — unavailable on this node");
|
|
1203
|
+
const def = registry.getDefinition(input.writerCapName);
|
|
1204
|
+
if (isDeviceConfigCap(def)) {
|
|
1205
|
+
if (def.deviceConfig.ui.kind === "widget") return { success: true };
|
|
1206
|
+
const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
|
|
1207
|
+
if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
|
|
1208
|
+
await applyDerivedFormPatch(def.deviceConfig.ui.builderId, { [input.key]: input.value }, (profile, patch) => dcProvider.setProfile({
|
|
1209
|
+
deviceId: input.deviceId,
|
|
1210
|
+
profile,
|
|
1211
|
+
patch
|
|
1212
|
+
}));
|
|
1213
|
+
return { success: true };
|
|
1214
|
+
}
|
|
1215
|
+
if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
|
|
1216
|
+
await this.resolveContributionProvider(registry, def, input.writerCapName, input.writerAddonId, input.deviceId).applyDeviceSettingsPatch({
|
|
1217
|
+
deviceId: input.deviceId,
|
|
1218
|
+
patch: { [input.key]: input.value }
|
|
1219
|
+
});
|
|
1220
|
+
return { success: true };
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Dispatch a device custom action. Hub-local devices run it directly;
|
|
1224
|
+
* forked/remote devices route through the `device-ops` native cap
|
|
1225
|
+
* (`runAction`) — the same hub-local-then-device-ops fan-out that
|
|
1226
|
+
* `updateDeviceField` uses for `applySettingsPatch`.
|
|
1227
|
+
*/
|
|
1228
|
+
async dispatchDeviceAction(deviceId, action, input) {
|
|
1229
|
+
const hubRegistry = this.ctx.kernel?.deviceRegistry;
|
|
1230
|
+
const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
|
|
1231
|
+
if (found) return found.device.runDeviceAction(action, input);
|
|
1232
|
+
const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
|
|
1233
|
+
if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found for runDeviceAction (no hub-local entry, no device-ops native provider)`);
|
|
1234
|
+
return ops.runAction({
|
|
1235
|
+
deviceId,
|
|
1236
|
+
action,
|
|
1237
|
+
input
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Resolve the `DeviceSettingsContribution` provider that owns a tagged
|
|
1242
|
+
* field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
|
|
1243
|
+
* from the binding that produced it — for `kind:'wrapped'` entries, that is
|
|
1244
|
+
* the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
|
|
1245
|
+
* the system addon id.
|
|
1246
|
+
*
|
|
1247
|
+
* Resolution order (Bug-3 fix):
|
|
1248
|
+
* 1. `getProviderByAddon(capName, writerAddonId)` — resolves the
|
|
1249
|
+
* system-registered provider by the addon id from the tagged field.
|
|
1250
|
+
* For wrapper bindings (snapshot, motion-detection, etc.) this directly
|
|
1251
|
+
* returns the wrapper provider, bypassing the native-first resolution
|
|
1252
|
+
* order of `getProviderForDevice` that caused "method not found".
|
|
1253
|
+
* 2. `getSingleton(capName)` — fallback for stale/mismatched writerAddonIds.
|
|
1254
|
+
* The active singleton handles the contribution even if the addonId
|
|
1255
|
+
* stored in the field is out of date (e.g. after an addon rename).
|
|
1256
|
+
*
|
|
1257
|
+
* `getProviderForDevice` is intentionally NOT used here: it returns the
|
|
1258
|
+
* per-device native first when present (e.g. Reolink/ONVIF), and the native
|
|
1259
|
+
* does NOT implement contribution methods — the root cause of Bug-3.
|
|
1260
|
+
*/
|
|
1261
|
+
resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
|
|
1262
|
+
const byAddon = registry.getProviderByAddon(writerCapName, writerAddonId);
|
|
1263
|
+
if (byAddon) return byAddon;
|
|
1264
|
+
const singleton = registry.getSingleton(writerCapName);
|
|
1265
|
+
if (singleton) return singleton;
|
|
1266
|
+
throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Batched counterpart of `updateDeviceField`. Groups changes by
|
|
1270
|
+
* `(writerCapName, writerAddonId)` so each contributor receives a
|
|
1271
|
+
* single `applyDeviceSettingsPatch` with all of its updates merged —
|
|
1272
|
+
* avoids N round-trips for simultaneous edits in the same save.
|
|
1273
|
+
*
|
|
1274
|
+
* Per-provider failures are captured in the `failures[]` output so the
|
|
1275
|
+
* admin UI can highlight which sections didn't persist; a failure on
|
|
1276
|
+
* one provider does NOT abort the others.
|
|
1277
|
+
*/
|
|
1278
|
+
async updateDeviceFieldsBatch(input) {
|
|
1279
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1280
|
+
for (const change of input.changes) {
|
|
1281
|
+
const key = `${change.writerCapName}::${change.writerAddonId}`;
|
|
1282
|
+
const existing = groups.get(key);
|
|
1283
|
+
if (existing) existing.patch[change.key] = change.value;
|
|
1284
|
+
else groups.set(key, {
|
|
1285
|
+
writerCapName: change.writerCapName,
|
|
1286
|
+
writerAddonId: change.writerAddonId,
|
|
1287
|
+
patch: { [change.key]: change.value }
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
const failures = [];
|
|
1291
|
+
for (const group of groups.values()) try {
|
|
1292
|
+
await this.applyGroupPatch(input.deviceId, group);
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
failures.push({
|
|
1295
|
+
writerCapName: group.writerCapName,
|
|
1296
|
+
writerAddonId: group.writerAddonId,
|
|
1297
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
return {
|
|
1301
|
+
success: true,
|
|
1302
|
+
failures
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/** Apply a single grouped patch to the appropriate provider. Mirrors
|
|
1306
|
+
* `updateDeviceField` routing (special-case device-manager, else
|
|
1307
|
+
* registry lookup). Used by `updateDeviceFieldsBatch`. */
|
|
1308
|
+
async applyGroupPatch(deviceId, group) {
|
|
1309
|
+
if (group.writerCapName === "device-manager") {
|
|
1310
|
+
const hubRegistry = this.ctx.kernel?.deviceRegistry;
|
|
1311
|
+
const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
|
|
1312
|
+
if (found) {
|
|
1313
|
+
await found.device.applySettingsPatch(group.patch);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
|
|
1317
|
+
if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found (no hub-local entry, no device-ops native provider)`);
|
|
1318
|
+
await ops.setConfig({
|
|
1319
|
+
deviceId,
|
|
1320
|
+
values: group.patch
|
|
1321
|
+
});
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const registry = this.capabilityRegistry;
|
|
1325
|
+
if (!registry) throw new Error("[device-manager] capability registry unavailable");
|
|
1326
|
+
const def = registry.getDefinition(group.writerCapName);
|
|
1327
|
+
if (isDeviceConfigCap(def)) {
|
|
1328
|
+
if (def.deviceConfig.ui.kind === "widget") return;
|
|
1329
|
+
const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
|
|
1330
|
+
if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
|
|
1331
|
+
await applyDerivedFormPatch(def.deviceConfig.ui.builderId, group.patch, (profile, patch) => dcProvider.setProfile({
|
|
1332
|
+
deviceId,
|
|
1333
|
+
profile,
|
|
1334
|
+
patch
|
|
1335
|
+
}));
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
|
|
1339
|
+
await this.resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
|
|
1340
|
+
deviceId,
|
|
1341
|
+
patch: group.patch
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
async listWrappersForCap(input) {
|
|
1345
|
+
return [...this.capabilityRegistry?.getWrappersForCap(input.capName) ?? []];
|
|
1346
|
+
}
|
|
1347
|
+
async listBindableCapsForDeviceType(input) {
|
|
1348
|
+
const registry = this.capabilityRegistry;
|
|
1349
|
+
if (!registry) return [];
|
|
1350
|
+
return registry.listDeviceScopedCapsForType(input.deviceType).map((capName) => ({
|
|
1351
|
+
capName,
|
|
1352
|
+
wrappers: [...registry.getWrappersForCap(capName)]
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
1355
|
+
async setWrapperActive(input) {
|
|
1356
|
+
const storeKey = String(input.deviceId);
|
|
1357
|
+
const store = await this.readBindingsStore();
|
|
1358
|
+
const perDevice = { ...store.deviceBindings[storeKey] };
|
|
1359
|
+
if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
|
|
1360
|
+
else perDevice[input.capName] = { wrapperAddonId: null };
|
|
1361
|
+
const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
|
|
1362
|
+
...store.deviceBindings,
|
|
1363
|
+
[storeKey]: perDevice
|
|
1364
|
+
} : (() => {
|
|
1365
|
+
const { [storeKey]: _drop, ...rest } = store.deviceBindings;
|
|
1366
|
+
return rest;
|
|
1367
|
+
})();
|
|
1368
|
+
await this.writeBindingsStore({ deviceBindings: nextDeviceBindings });
|
|
1369
|
+
this.ctx.eventBus.emit({
|
|
1370
|
+
id: randomUUID(),
|
|
1371
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1372
|
+
source: {
|
|
1373
|
+
type: "addon",
|
|
1374
|
+
id: this.ctx.id
|
|
1375
|
+
},
|
|
1376
|
+
category: EventCategory.DeviceBindingsChanged,
|
|
1377
|
+
data: {
|
|
1378
|
+
deviceId: input.deviceId,
|
|
1379
|
+
capName: input.capName,
|
|
1380
|
+
reason: input.active ? "wrapper-activated" : "wrapper-deactivated",
|
|
1381
|
+
addonId: input.wrapperAddonId,
|
|
1382
|
+
nodeId: this.resolveWrapperNodeId(input.wrapperAddonId)
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
async onInitialize() {
|
|
1387
|
+
const settings = this.ctx.settings;
|
|
1388
|
+
if (!settings) {
|
|
1389
|
+
this.ctx.logger.warn("ctx.settings not available — device persistence unavailable");
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
const registry = this.ctx.kernel.deviceRegistry ?? null;
|
|
1393
|
+
if (!registry) this.ctx.logger.warn("device-registry not available — live operations will use persisted data only");
|
|
1394
|
+
const localNodeId = this.ctx.kernel.localNodeId ?? "hub";
|
|
1395
|
+
this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
|
|
1396
|
+
const { deviceId, capName, reason, addonId, nodeId } = event.data;
|
|
1397
|
+
if (nodeId === localNodeId) return;
|
|
1398
|
+
if (reason === "native-registered") {
|
|
1399
|
+
let perDevice = this.remoteNativeCaps.get(deviceId);
|
|
1400
|
+
if (!perDevice) {
|
|
1401
|
+
perDevice = /* @__PURE__ */ new Map();
|
|
1402
|
+
this.remoteNativeCaps.set(deviceId, perDevice);
|
|
1403
|
+
}
|
|
1404
|
+
perDevice.set(capName, {
|
|
1405
|
+
addonId,
|
|
1406
|
+
nodeId
|
|
1407
|
+
});
|
|
1408
|
+
} else if (reason === "native-unregistered") {
|
|
1409
|
+
const perDevice = this.remoteNativeCaps.get(deviceId);
|
|
1410
|
+
if (!perDevice) return;
|
|
1411
|
+
perDevice.delete(capName);
|
|
1412
|
+
if (perDevice.size === 0) this.remoteNativeCaps.delete(deviceId);
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
const cluster = this.ctx.kernel.cluster;
|
|
1416
|
+
if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
|
|
1417
|
+
const gone = payload.node.id;
|
|
1418
|
+
const emptyDevices = [];
|
|
1419
|
+
for (const [deviceId, perDevice] of this.remoteNativeCaps) {
|
|
1420
|
+
const toDelete = [];
|
|
1421
|
+
for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
|
|
1422
|
+
for (const capName of toDelete) perDevice.delete(capName);
|
|
1423
|
+
if (perDevice.size === 0) emptyDevices.push(deviceId);
|
|
1424
|
+
}
|
|
1425
|
+
for (const deviceId of emptyDevices) this.remoteNativeCaps.delete(deviceId);
|
|
1426
|
+
});
|
|
1427
|
+
const requireDeviceOps = (deviceId) => {
|
|
1428
|
+
const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
|
|
1429
|
+
if (!ops) throw new Error(`[device-manager] device-ops native provider not found for '${deviceId}'`);
|
|
1430
|
+
return ops;
|
|
1431
|
+
};
|
|
1432
|
+
const readStore = async () => {
|
|
1433
|
+
return await settings.readAddonStore();
|
|
1434
|
+
};
|
|
1435
|
+
const readIndex = async () => {
|
|
1436
|
+
return (await readStore()).deviceIndex ?? {};
|
|
1437
|
+
};
|
|
1438
|
+
const readMeta = async () => {
|
|
1439
|
+
return (await readStore()).deviceMeta ?? {};
|
|
1440
|
+
};
|
|
1441
|
+
/** Hardware-identity metadata map. Lives in a sibling key on the
|
|
1442
|
+
* device-manager addon store so its writers (`setMetadata`) never
|
|
1443
|
+
* collide with the lifecycle writers on `deviceMeta`
|
|
1444
|
+
* (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
|
|
1445
|
+
* Single-writer per row eliminates the "writer X clobbers writer
|
|
1446
|
+
* Y's field" bug class — `setMetadata` is the only producer. */
|
|
1447
|
+
const readMetadataMap = async () => {
|
|
1448
|
+
return (await readStore()).deviceMetadata ?? {};
|
|
1449
|
+
};
|
|
1450
|
+
let metaWriteChain = Promise.resolve();
|
|
1451
|
+
const withMetaWriteLock = async (fn) => {
|
|
1452
|
+
const previous = metaWriteChain;
|
|
1453
|
+
let release = () => {};
|
|
1454
|
+
metaWriteChain = new Promise((resolve) => {
|
|
1455
|
+
release = resolve;
|
|
1456
|
+
});
|
|
1457
|
+
try {
|
|
1458
|
+
await previous.catch(() => {});
|
|
1459
|
+
return await fn();
|
|
1460
|
+
} finally {
|
|
1461
|
+
release();
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
/**
|
|
1465
|
+
* Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
|
|
1466
|
+
* Scans persisted meta — live IDevice lookup (hub registry) is handled
|
|
1467
|
+
* separately per call site so callers can decide whether to route to
|
|
1468
|
+
* an in-process driver or to the cross-process `device-ops` bridge.
|
|
1469
|
+
* Returns null when no device with that id is known to the hub.
|
|
1470
|
+
*/
|
|
1471
|
+
const resolvePersistedById = async (deviceId) => {
|
|
1472
|
+
const meta = await readMeta();
|
|
1473
|
+
for (const [key, m] of Object.entries(meta)) if (m.id === deviceId) {
|
|
1474
|
+
const sep = key.indexOf(":");
|
|
1475
|
+
if (sep < 0) continue;
|
|
1476
|
+
return {
|
|
1477
|
+
addonId: key.slice(0, sep),
|
|
1478
|
+
stableId: key.slice(sep + 1),
|
|
1479
|
+
meta: m
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
return null;
|
|
1483
|
+
};
|
|
1484
|
+
/** Resolve a link's `sourceKey` to a live device id: the sibling accessory
|
|
1485
|
+
* whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
|
|
1486
|
+
* Pure over a pre-read meta map so a multi-link resolve reads the store once. */
|
|
1487
|
+
const resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
|
|
1488
|
+
const wanted = `${parentStableId}-${sourceKey}`;
|
|
1489
|
+
for (const [key, m] of Object.entries(meta)) {
|
|
1490
|
+
const sep = key.indexOf(":");
|
|
1491
|
+
if (sep < 0) continue;
|
|
1492
|
+
if (key.slice(sep + 1) === wanted) return m.id;
|
|
1493
|
+
}
|
|
1494
|
+
return null;
|
|
1495
|
+
};
|
|
1496
|
+
/** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
|
|
1497
|
+
* current persisted meta. Called at boot (once the `devicesWithLinks` seed
|
|
1498
|
+
* has run) and whenever the link topology can change: `setDeviceLinks`,
|
|
1499
|
+
* `registerDevice` (a new sibling source may resolve previously-dangling
|
|
1500
|
+
* links), and `removeDevice` (its entries must be evicted). */
|
|
1501
|
+
const rebuildLinkDependents = async () => {
|
|
1502
|
+
if (this.devicesWithLinks.size === 0) {
|
|
1503
|
+
this.linkTargets = /* @__PURE__ */ new Map();
|
|
1504
|
+
this.linkDependents = /* @__PURE__ */ new Map();
|
|
1505
|
+
this.expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
const allMeta = await readMeta();
|
|
1509
|
+
const stableIdById = /* @__PURE__ */ new Map();
|
|
1510
|
+
const idByStableId = /* @__PURE__ */ new Map();
|
|
1511
|
+
for (const [key, m] of Object.entries(allMeta)) {
|
|
1512
|
+
const sep = key.indexOf(":");
|
|
1513
|
+
if (sep < 0) continue;
|
|
1514
|
+
const sid = key.slice(sep + 1);
|
|
1515
|
+
stableIdById.set(m.id, sid);
|
|
1516
|
+
idByStableId.set(sid, m.id);
|
|
1517
|
+
}
|
|
1518
|
+
const entries = [];
|
|
1519
|
+
const expectedSources = /* @__PURE__ */ new Set();
|
|
1520
|
+
for (const targetId of this.devicesWithLinks) {
|
|
1521
|
+
const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
|
|
1522
|
+
if (!targetMeta) continue;
|
|
1523
|
+
const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
|
|
1524
|
+
if (container === void 0) continue;
|
|
1525
|
+
for (const link of targetMeta.deviceLinks ?? []) {
|
|
1526
|
+
expectedSources.add(`${container}-${link.source.sourceKey}`);
|
|
1527
|
+
const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
|
|
1528
|
+
if (srcId === void 0) continue;
|
|
1529
|
+
entries.push({
|
|
1530
|
+
targetDeviceId: targetId,
|
|
1531
|
+
link,
|
|
1532
|
+
sourceDeviceId: srcId
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const { targets, dependents } = buildLinkIndexes(entries);
|
|
1537
|
+
this.linkTargets = targets;
|
|
1538
|
+
this.linkDependents = dependents;
|
|
1539
|
+
this.expectedSourceStableIds = expectedSources;
|
|
1540
|
+
};
|
|
1541
|
+
const idToAddonId = /* @__PURE__ */ new Map();
|
|
1542
|
+
{
|
|
1543
|
+
const meta = await readMeta();
|
|
1544
|
+
for (const [key, m] of Object.entries(meta)) {
|
|
1545
|
+
const sep = key.indexOf(":");
|
|
1546
|
+
if (sep < 0) continue;
|
|
1547
|
+
idToAddonId.set(m.id, key.slice(0, sep));
|
|
1548
|
+
if (Array.isArray(m.deviceLinks) && m.deviceLinks.length > 0) this.devicesWithLinks.add(m.id);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
await rebuildLinkDependents();
|
|
1552
|
+
const allocateNextDeviceId = async () => {
|
|
1553
|
+
const current = (await readStore()).nextDeviceId ?? 1;
|
|
1554
|
+
await settings.writeAddonStore({ nextDeviceId: current + 1 });
|
|
1555
|
+
return current;
|
|
1556
|
+
};
|
|
1557
|
+
const stampIntegrationId = async (deviceId, integrationId) => {
|
|
1558
|
+
await withMetaWriteLock(async () => {
|
|
1559
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1560
|
+
if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
|
|
1561
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
1562
|
+
const key = deviceKey(aid, stableId);
|
|
1563
|
+
const allMeta = await readMeta();
|
|
1564
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1565
|
+
...allMeta,
|
|
1566
|
+
[key]: {
|
|
1567
|
+
...m,
|
|
1568
|
+
integrationId
|
|
1569
|
+
}
|
|
1570
|
+
} });
|
|
1571
|
+
});
|
|
1572
|
+
this.ctx.eventBus.emit({
|
|
1573
|
+
id: randomUUID(),
|
|
1574
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1575
|
+
source: {
|
|
1576
|
+
type: "device",
|
|
1577
|
+
id: deviceId
|
|
1578
|
+
},
|
|
1579
|
+
category: EventCategory.DeviceMetaChanged,
|
|
1580
|
+
data: {
|
|
1581
|
+
deviceId,
|
|
1582
|
+
field: "integrationId",
|
|
1583
|
+
value: integrationId
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
};
|
|
1587
|
+
const provider = {
|
|
1588
|
+
/** Sync ownership lookup backing persistence fallbacks (e.g. remove()
|
|
1589
|
+
* when the owning worker is offline). Ownership is keyed by numeric
|
|
1590
|
+
* deviceId → owning addonId as recorded in the persisted meta. NOT
|
|
1591
|
+
* a native-cap lookup: an addon can own a device without registering
|
|
1592
|
+
* every possible cap natively (e.g. RtspCamera without snapshotUrl
|
|
1593
|
+
* doesn't register the snapshot cap). Use
|
|
1594
|
+
* `resolveNativeCapOwnerSync` for cap-resolution paths.
|
|
1595
|
+
*/
|
|
1596
|
+
resolveDeviceOwnerSync: (deviceId) => {
|
|
1597
|
+
return idToAddonId.get(deviceId) ?? null;
|
|
1598
|
+
},
|
|
1599
|
+
/** Sync lookup for the addon that registered a native provider for
|
|
1600
|
+
* `(capName, deviceId)`. Backs `CapabilityRegistry`'s native fallback
|
|
1601
|
+
* so the hub only synthesizes a cross-process proxy when the cap is
|
|
1602
|
+
* actually published — never on speculative device ownership.
|
|
1603
|
+
*
|
|
1604
|
+
* Resolution order:
|
|
1605
|
+
* 1. Hub-local `capabilityRegistry` (in-process natives — fastest path).
|
|
1606
|
+
* 2. Push-fed `remoteNativeCaps` cache (`DeviceBindingsChanged` events
|
|
1607
|
+
* from forked workers — accurate in steady state).
|
|
1608
|
+
* 3. Handshake-fed `HubNodeRegistry` via `listClusterNativeCaps()`
|
|
1609
|
+
* (D3 re-handshake after device restore — covers the Moleculer
|
|
1610
|
+
* transport window where push events were lost). This is the
|
|
1611
|
+
* reliable replacement for the deleted `syncWorkerNativeCaps` pull.
|
|
1612
|
+
*/
|
|
1613
|
+
resolveNativeCapOwnerSync: (capName, deviceId) => {
|
|
1614
|
+
const localAddonId = this.capabilityRegistry?.getNativeAddonId(capName, deviceId) ?? null;
|
|
1615
|
+
if (localAddonId) return {
|
|
1616
|
+
addonId: localAddonId,
|
|
1617
|
+
nodeId: this.ctx.kernel.localNodeId ?? "hub"
|
|
1618
|
+
};
|
|
1619
|
+
const remote = this.remoteNativeCaps.get(deviceId)?.get(capName) ?? null;
|
|
1620
|
+
if (remote) return {
|
|
1621
|
+
addonId: remote.addonId,
|
|
1622
|
+
nodeId: remote.nodeId
|
|
1623
|
+
};
|
|
1624
|
+
return this.resolveRemoteNativeCapFromRegistry(capName, deviceId);
|
|
1625
|
+
},
|
|
1626
|
+
/** Read-time overlay: merge cross-device linked values onto a target cap's
|
|
1627
|
+
* status. Returns null when the device has no links for `cap` (caller uses
|
|
1628
|
+
* the base status untouched). Reads each source cap via the hub registry's
|
|
1629
|
+
* `getProviderForDevice` (routes cross-process); merge is pure. */
|
|
1630
|
+
resolveLinkedStatus: async ({ deviceId, cap, baseStatus }) => {
|
|
1631
|
+
if (!this.devicesWithLinks.has(deviceId)) return null;
|
|
1632
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1633
|
+
if (!persisted) return null;
|
|
1634
|
+
const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
|
|
1635
|
+
if (links.length === 0) return null;
|
|
1636
|
+
const registry = this.capabilityRegistry;
|
|
1637
|
+
const schema = registry?.getDefinition(cap)?.status?.schema;
|
|
1638
|
+
if (!schema) return null;
|
|
1639
|
+
const allMeta = await readMeta();
|
|
1640
|
+
let containerStableId = persisted.stableId;
|
|
1641
|
+
const parentId = persisted.meta.parentDeviceId;
|
|
1642
|
+
if (parentId !== null) {
|
|
1643
|
+
for (const [key, m] of Object.entries(allMeta)) if (m.id === parentId) {
|
|
1644
|
+
containerStableId = key.slice(key.indexOf(":") + 1);
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
const resolved = [];
|
|
1649
|
+
for (const link of links) {
|
|
1650
|
+
const srcId = resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
|
|
1651
|
+
if (srcId === null) continue;
|
|
1652
|
+
try {
|
|
1653
|
+
const srcProvider = registry?.getProviderForDevice(link.source.cap, srcId);
|
|
1654
|
+
const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
|
|
1655
|
+
resolved.push({
|
|
1656
|
+
link,
|
|
1657
|
+
sourceValue: applyTransform(raw, link.transform)
|
|
1658
|
+
});
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
this.ctx.logger.warn("resolveLinkedStatus: source read failed", {
|
|
1661
|
+
tags: {
|
|
1662
|
+
deviceId,
|
|
1663
|
+
capName: cap
|
|
1664
|
+
},
|
|
1665
|
+
meta: {
|
|
1666
|
+
sourceKey: link.source.sourceKey,
|
|
1667
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (resolved.length === 0) return null;
|
|
1673
|
+
return mergeLinkedStatus(isRecord(baseStatus) ? baseStatus : {}, resolved, schema);
|
|
1674
|
+
},
|
|
1675
|
+
/** Idempotent numeric-id reservation. Callers invoke this before
|
|
1676
|
+
* constructing the owning `IDevice` so `DeviceContext.id` is bound
|
|
1677
|
+
* at construction time. A repeat call for the same `(addonId,
|
|
1678
|
+
* stableId)` returns the already-persisted id — same physical
|
|
1679
|
+
* device reconnecting after a driver restart keeps its original
|
|
1680
|
+
* number. Fresh pairs burn one slot from the monotonic
|
|
1681
|
+
* `nextDeviceId` counter and seed a meta placeholder so the
|
|
1682
|
+
* `deviceMeta` → `id` invariant holds even before
|
|
1683
|
+
* `registerDevice` completes. */
|
|
1684
|
+
allocateDeviceId: async (input) => {
|
|
1685
|
+
const { addonId, stableId } = input;
|
|
1686
|
+
const key = deviceKey(addonId, stableId);
|
|
1687
|
+
return await withMetaWriteLock(async () => {
|
|
1688
|
+
const meta = await readMeta();
|
|
1689
|
+
const existing = meta[key];
|
|
1690
|
+
if (existing) return { id: existing.id };
|
|
1691
|
+
const id = await allocateNextDeviceId();
|
|
1692
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1693
|
+
...meta,
|
|
1694
|
+
[key]: {
|
|
1695
|
+
type: "generic",
|
|
1696
|
+
name: stableId,
|
|
1697
|
+
location: null,
|
|
1698
|
+
disabled: false,
|
|
1699
|
+
parentDeviceId: null,
|
|
1700
|
+
id
|
|
1701
|
+
}
|
|
1702
|
+
} });
|
|
1703
|
+
return { id };
|
|
1704
|
+
});
|
|
1705
|
+
},
|
|
1706
|
+
registerDevice: async (input) => {
|
|
1707
|
+
const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
|
|
1708
|
+
const key = deviceKey(addonId, stableId);
|
|
1709
|
+
const featuresArr = Array.isArray(features) ? [...features] : [];
|
|
1710
|
+
const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await withMetaWriteLock(async () => {
|
|
1711
|
+
const index = await readIndex();
|
|
1712
|
+
const existing = index[addonId] ?? [];
|
|
1713
|
+
const wasInIndex = existing.includes(stableId);
|
|
1714
|
+
if (!wasInIndex) await settings.writeAddonStore({ deviceIndex: {
|
|
1715
|
+
...index,
|
|
1716
|
+
[addonId]: [...existing, stableId]
|
|
1717
|
+
} });
|
|
1718
|
+
const meta = await readMeta();
|
|
1719
|
+
const existingMeta = meta[key];
|
|
1720
|
+
const isFirst = !existingMeta || !wasInIndex;
|
|
1721
|
+
const fingerprint = canonicalDeviceFingerprint({
|
|
1722
|
+
deviceId: id,
|
|
1723
|
+
deviceType: type,
|
|
1724
|
+
features: featuresArr
|
|
1725
|
+
});
|
|
1726
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1727
|
+
...meta,
|
|
1728
|
+
[key]: {
|
|
1729
|
+
type,
|
|
1730
|
+
name: existingMeta && existingMeta.name !== stableId ? existingMeta.name : name,
|
|
1731
|
+
location: existingMeta?.location ?? null,
|
|
1732
|
+
disabled: existingMeta?.disabled ?? false,
|
|
1733
|
+
...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
|
|
1734
|
+
...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
|
|
1735
|
+
...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
|
|
1736
|
+
...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
|
|
1737
|
+
...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
|
|
1738
|
+
...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
|
|
1739
|
+
parentDeviceId,
|
|
1740
|
+
id,
|
|
1741
|
+
features: featuresArr,
|
|
1742
|
+
exportFingerprint: fingerprint
|
|
1743
|
+
}
|
|
1744
|
+
} });
|
|
1745
|
+
return {
|
|
1746
|
+
isFirstRegistration: isFirst,
|
|
1747
|
+
exportFingerprint: fingerprint,
|
|
1748
|
+
fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
|
|
1749
|
+
};
|
|
1750
|
+
});
|
|
1751
|
+
if (Object.keys(config).length > 0) await settings.writeDeviceStore(id, config);
|
|
1752
|
+
idToAddonId.set(id, addonId);
|
|
1753
|
+
if (isFirstRegistration) this.ctx.eventBus.emit({
|
|
1754
|
+
id: randomUUID(),
|
|
1755
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1756
|
+
source: {
|
|
1757
|
+
type: "device",
|
|
1758
|
+
id
|
|
1759
|
+
},
|
|
1760
|
+
category: EventCategory.DeviceRegistered,
|
|
1761
|
+
data: {
|
|
1762
|
+
deviceId: id,
|
|
1763
|
+
name: name.length > 0 ? name : stableId,
|
|
1764
|
+
providerId: addonId,
|
|
1765
|
+
parentDeviceId: parentDeviceId ?? null
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
else this.ctx.eventBus.emit({
|
|
1769
|
+
id: randomUUID(),
|
|
1770
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1771
|
+
source: {
|
|
1772
|
+
type: "device",
|
|
1773
|
+
id
|
|
1774
|
+
},
|
|
1775
|
+
category: EventCategory.DeviceMetaChanged,
|
|
1776
|
+
data: {
|
|
1777
|
+
deviceId: id,
|
|
1778
|
+
name: name.length > 0 ? name : stableId,
|
|
1779
|
+
providerId: addonId,
|
|
1780
|
+
parentDeviceId: parentDeviceId ?? null,
|
|
1781
|
+
features: featuresArr
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
if (isFirstRegistration || fingerprintChanged) this.ctx.eventBus.emit({
|
|
1785
|
+
id: randomUUID(),
|
|
1786
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1787
|
+
source: {
|
|
1788
|
+
type: "device",
|
|
1789
|
+
id
|
|
1790
|
+
},
|
|
1791
|
+
category: EventCategory.DeviceProvisioned,
|
|
1792
|
+
data: {
|
|
1793
|
+
deviceId: id,
|
|
1794
|
+
fingerprint: exportFingerprint,
|
|
1795
|
+
generation: Date.now()
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
if (this.devicesWithLinks.size > 0 && this.expectedSourceStableIds.has(stableId)) await rebuildLinkDependents();
|
|
1799
|
+
},
|
|
1800
|
+
removeDevice: async (input) => {
|
|
1801
|
+
const { deviceId } = input;
|
|
1802
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1803
|
+
if (!persisted) return;
|
|
1804
|
+
const { addonId, stableId, meta: persistedMeta } = persisted;
|
|
1805
|
+
const key = deviceKey(addonId, stableId);
|
|
1806
|
+
const deviceName = persistedMeta.name;
|
|
1807
|
+
await withMetaWriteLock(async () => {
|
|
1808
|
+
const index = await readIndex();
|
|
1809
|
+
const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
|
|
1810
|
+
const updatedIndex = remaining.length > 0 ? {
|
|
1811
|
+
...index,
|
|
1812
|
+
[addonId]: remaining
|
|
1813
|
+
} : (() => {
|
|
1814
|
+
const { [addonId]: _removed, ...rest } = index;
|
|
1815
|
+
return rest;
|
|
1816
|
+
})();
|
|
1817
|
+
await settings.writeAddonStore({ deviceIndex: updatedIndex });
|
|
1818
|
+
const { [key]: _removedMeta, ...restMeta } = await readMeta();
|
|
1819
|
+
await settings.writeAddonStore({ deviceMeta: restMeta });
|
|
1820
|
+
const map = await readMetadataMap();
|
|
1821
|
+
if (key in map) {
|
|
1822
|
+
const { [key]: _removedMetadata, ...restMap } = map;
|
|
1823
|
+
await settings.writeAddonStore({ deviceMetadata: restMap });
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
await settings.clearDeviceStore(deviceId);
|
|
1827
|
+
await settings.clearDeviceRuntimeState(deviceId);
|
|
1828
|
+
const bindingsStore = await this.readBindingsStore();
|
|
1829
|
+
const bindingKey = String(deviceId);
|
|
1830
|
+
if (bindingsStore.deviceBindings[bindingKey]) {
|
|
1831
|
+
const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
|
|
1832
|
+
await this.writeBindingsStore({ deviceBindings: restBindings });
|
|
1833
|
+
}
|
|
1834
|
+
this.remoteNativeCaps.delete(deviceId);
|
|
1835
|
+
this.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
|
|
1836
|
+
idToAddonId.delete(deviceId);
|
|
1837
|
+
this.devicesWithLinks.delete(deviceId);
|
|
1838
|
+
for (const key of this.lastEmittedOverlay.keys()) if (key.startsWith(`${deviceId}:`)) this.lastEmittedOverlay.delete(key);
|
|
1839
|
+
await rebuildLinkDependents();
|
|
1840
|
+
this.ctx.logger.info("removed device", { tags: {
|
|
1841
|
+
deviceId,
|
|
1842
|
+
deviceName: deviceName.length > 0 ? deviceName : stableId
|
|
1843
|
+
} });
|
|
1844
|
+
this.ctx.eventBus.emit({
|
|
1845
|
+
id: randomUUID(),
|
|
1846
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1847
|
+
source: {
|
|
1848
|
+
type: "device",
|
|
1849
|
+
id: deviceId
|
|
1850
|
+
},
|
|
1851
|
+
category: EventCategory.DeviceUnregistered,
|
|
1852
|
+
data: {
|
|
1853
|
+
deviceId,
|
|
1854
|
+
providerId: addonId,
|
|
1855
|
+
parentDeviceId: persistedMeta.parentDeviceId ?? null
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
},
|
|
1859
|
+
persistConfig: async (input) => {
|
|
1860
|
+
const { deviceId, data } = input;
|
|
1861
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] persistConfig: unknown device id=${deviceId}`);
|
|
1862
|
+
await settings.writeDeviceStore(deviceId, data);
|
|
1863
|
+
},
|
|
1864
|
+
loadConfig: async (input) => {
|
|
1865
|
+
const { deviceId } = input;
|
|
1866
|
+
if (!await resolvePersistedById(deviceId)) return {};
|
|
1867
|
+
return settings.readDeviceStore(deviceId);
|
|
1868
|
+
},
|
|
1869
|
+
/**
|
|
1870
|
+
* Load the operator-organisational meta surface for one device
|
|
1871
|
+
* (`name` / `location` / `disabled` / `type` / `parentDeviceId`
|
|
1872
|
+
* / `addonId` + `id` / `stableId`). Used by the kernel proxy's
|
|
1873
|
+
* device-context factory to populate `ctx.deviceMeta` before
|
|
1874
|
+
* the device class constructor runs. Returns `null` when no
|
|
1875
|
+
* persisted row exists for the id.
|
|
1876
|
+
*
|
|
1877
|
+
* Reads default `location` to `null` and `disabled` to `false`
|
|
1878
|
+
* for legacy rows that predate the field — production code
|
|
1879
|
+
* relies on the IDevice type contract that both are present.
|
|
1880
|
+
*/
|
|
1881
|
+
loadMeta: async (input) => {
|
|
1882
|
+
const { deviceId } = input;
|
|
1883
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1884
|
+
if (!persisted) return null;
|
|
1885
|
+
const { addonId, stableId, meta: m } = persisted;
|
|
1886
|
+
const key = deviceKey(addonId, stableId);
|
|
1887
|
+
const metadata = (await readMetadataMap())[key] ?? null;
|
|
1888
|
+
return {
|
|
1889
|
+
id: m.id,
|
|
1890
|
+
stableId,
|
|
1891
|
+
addonId,
|
|
1892
|
+
type: m.type,
|
|
1893
|
+
name: m.name,
|
|
1894
|
+
location: m.location ?? null,
|
|
1895
|
+
disabled: m.disabled ?? false,
|
|
1896
|
+
parentDeviceId: m.parentDeviceId,
|
|
1897
|
+
metadata,
|
|
1898
|
+
...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
|
|
1899
|
+
...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
|
|
1900
|
+
...m.role !== void 0 ? { role: m.role } : {}
|
|
1901
|
+
};
|
|
1902
|
+
},
|
|
1903
|
+
/**
|
|
1904
|
+
* Update the operator-edited display name. Writes the meta
|
|
1905
|
+
* row, emits a `DeviceMetaChanged` event so live consumers
|
|
1906
|
+
* (UI device list, alert center) see the rename without
|
|
1907
|
+
* polling. The live `IDevice.name` mirror is updated by the
|
|
1908
|
+
* kernel proxy on its side (`device-cap-proxy.ts`).
|
|
1909
|
+
*/
|
|
1910
|
+
setName: async (input) => {
|
|
1911
|
+
const { deviceId, name } = input;
|
|
1912
|
+
await withMetaWriteLock(async () => {
|
|
1913
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1914
|
+
if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
|
|
1915
|
+
const { addonId, stableId, meta: m } = persisted;
|
|
1916
|
+
const key = deviceKey(addonId, stableId);
|
|
1917
|
+
const allMeta = await readMeta();
|
|
1918
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1919
|
+
...allMeta,
|
|
1920
|
+
[key]: {
|
|
1921
|
+
...m,
|
|
1922
|
+
name
|
|
1923
|
+
}
|
|
1924
|
+
} });
|
|
1925
|
+
});
|
|
1926
|
+
this.ctx.eventBus.emit({
|
|
1927
|
+
id: randomUUID(),
|
|
1928
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1929
|
+
source: {
|
|
1930
|
+
type: "device",
|
|
1931
|
+
id: deviceId
|
|
1932
|
+
},
|
|
1933
|
+
category: EventCategory.DeviceMetaChanged,
|
|
1934
|
+
data: {
|
|
1935
|
+
deviceId,
|
|
1936
|
+
field: "name",
|
|
1937
|
+
value: name
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
},
|
|
1941
|
+
/**
|
|
1942
|
+
* Update the operator-organisational location label. `null`
|
|
1943
|
+
* clears it. Mirrors the same persist-then-emit shape as
|
|
1944
|
+
* `setName`; consumers subscribe to `DeviceMetaChanged` and
|
|
1945
|
+
* filter on `field: 'location'`.
|
|
1946
|
+
*/
|
|
1947
|
+
setLocation: async (input) => {
|
|
1948
|
+
const { deviceId, location } = input;
|
|
1949
|
+
await withMetaWriteLock(async () => {
|
|
1950
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1951
|
+
if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
|
|
1952
|
+
const { addonId, stableId, meta: m } = persisted;
|
|
1953
|
+
const key = deviceKey(addonId, stableId);
|
|
1954
|
+
const allMeta = await readMeta();
|
|
1955
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1956
|
+
...allMeta,
|
|
1957
|
+
[key]: {
|
|
1958
|
+
...m,
|
|
1959
|
+
location
|
|
1960
|
+
}
|
|
1961
|
+
} });
|
|
1962
|
+
});
|
|
1963
|
+
this.ctx.eventBus.emit({
|
|
1964
|
+
id: randomUUID(),
|
|
1965
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1966
|
+
source: {
|
|
1967
|
+
type: "device",
|
|
1968
|
+
id: deviceId
|
|
1969
|
+
},
|
|
1970
|
+
category: EventCategory.DeviceMetaChanged,
|
|
1971
|
+
data: {
|
|
1972
|
+
deviceId,
|
|
1973
|
+
field: "location",
|
|
1974
|
+
value: location
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
},
|
|
1978
|
+
/**
|
|
1979
|
+
* Update the device type. Writes the meta row, emits a
|
|
1980
|
+
* `DeviceMetaChanged` event. Used by the kernel to apply
|
|
1981
|
+
* `initialMeta.type` before device construction so the device
|
|
1982
|
+
* constructs with its real type instead of the `allocateDeviceId`
|
|
1983
|
+
* placeholder `'generic'`.
|
|
1984
|
+
*/
|
|
1985
|
+
setType: async (input) => {
|
|
1986
|
+
const { deviceId, type } = input;
|
|
1987
|
+
await withMetaWriteLock(async () => {
|
|
1988
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
1989
|
+
if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
|
|
1990
|
+
const { addonId, stableId, meta: m } = persisted;
|
|
1991
|
+
const key = deviceKey(addonId, stableId);
|
|
1992
|
+
const allMeta = await readMeta();
|
|
1993
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
1994
|
+
...allMeta,
|
|
1995
|
+
[key]: {
|
|
1996
|
+
...m,
|
|
1997
|
+
type
|
|
1998
|
+
}
|
|
1999
|
+
} });
|
|
2000
|
+
});
|
|
2001
|
+
this.ctx.eventBus.emit({
|
|
2002
|
+
id: randomUUID(),
|
|
2003
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2004
|
+
source: {
|
|
2005
|
+
type: "device",
|
|
2006
|
+
id: deviceId
|
|
2007
|
+
},
|
|
2008
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2009
|
+
data: {
|
|
2010
|
+
deviceId,
|
|
2011
|
+
field: "type",
|
|
2012
|
+
value: type
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
},
|
|
2016
|
+
/**
|
|
2017
|
+
* Stamp (or update) the owning integration id on the device's
|
|
2018
|
+
* meta row. Called by the kernel's `create()` pre-seed path
|
|
2019
|
+
* when `initialMeta.integrationId` is set (analogous to
|
|
2020
|
+
* `setName` / `setType`). Idempotent.
|
|
2021
|
+
*/
|
|
2022
|
+
setIntegrationId: async (input) => {
|
|
2023
|
+
await stampIntegrationId(input.deviceId, input.integrationId);
|
|
2024
|
+
},
|
|
2025
|
+
/**
|
|
2026
|
+
* Stamp (or update) the linked device id on the device's meta
|
|
2027
|
+
* row. Called by the kernel's `create()` pre-seed path when
|
|
2028
|
+
* `initialMeta.linkDeviceId` is set (analogous to `setIntegrationId`).
|
|
2029
|
+
* Idempotent. `null` explicitly clears a previous link.
|
|
2030
|
+
*/
|
|
2031
|
+
setLinkDeviceId: async (input) => {
|
|
2032
|
+
const { deviceId, linkDeviceId } = input;
|
|
2033
|
+
await withMetaWriteLock(async () => {
|
|
2034
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2035
|
+
if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
|
|
2036
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2037
|
+
const key = deviceKey(aid, stableId);
|
|
2038
|
+
const allMeta = await readMeta();
|
|
2039
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2040
|
+
...allMeta,
|
|
2041
|
+
[key]: {
|
|
2042
|
+
...m,
|
|
2043
|
+
linkDeviceId
|
|
2044
|
+
}
|
|
2045
|
+
} });
|
|
2046
|
+
});
|
|
2047
|
+
this.ctx.eventBus.emit({
|
|
2048
|
+
id: randomUUID(),
|
|
2049
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2050
|
+
source: {
|
|
2051
|
+
type: "device",
|
|
2052
|
+
id: deviceId
|
|
2053
|
+
},
|
|
2054
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2055
|
+
data: {
|
|
2056
|
+
deviceId,
|
|
2057
|
+
field: "linkDeviceId",
|
|
2058
|
+
value: linkDeviceId
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
},
|
|
2062
|
+
/**
|
|
2063
|
+
* Set (or clear) the durable primary-child override on a CONTAINER
|
|
2064
|
+
* device's meta row, keyed on the chosen child's re-sync/rename-stable
|
|
2065
|
+
* `entityId`. Mirrors `setLinkDeviceId` but persists a string entityId
|
|
2066
|
+
* (survives a re-sync that reallocates the child's numeric id) instead
|
|
2067
|
+
* of the numeric child id. `null` clears the override → priority default.
|
|
2068
|
+
*/
|
|
2069
|
+
setPrimaryChildEntityId: async (input) => {
|
|
2070
|
+
const { deviceId, primaryChildEntityId } = input;
|
|
2071
|
+
await withMetaWriteLock(async () => {
|
|
2072
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2073
|
+
if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
|
|
2074
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2075
|
+
const key = deviceKey(aid, stableId);
|
|
2076
|
+
const allMeta = await readMeta();
|
|
2077
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2078
|
+
...allMeta,
|
|
2079
|
+
[key]: {
|
|
2080
|
+
...m,
|
|
2081
|
+
primaryChildEntityId
|
|
2082
|
+
}
|
|
2083
|
+
} });
|
|
2084
|
+
});
|
|
2085
|
+
this.ctx.eventBus.emit({
|
|
2086
|
+
id: randomUUID(),
|
|
2087
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2088
|
+
source: {
|
|
2089
|
+
type: "device",
|
|
2090
|
+
id: deviceId
|
|
2091
|
+
},
|
|
2092
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2093
|
+
data: {
|
|
2094
|
+
deviceId,
|
|
2095
|
+
field: "primaryChildEntityId",
|
|
2096
|
+
value: primaryChildEntityId
|
|
2097
|
+
}
|
|
2098
|
+
});
|
|
2099
|
+
},
|
|
2100
|
+
/**
|
|
2101
|
+
* Set (or replace) the container-level `childLayout` on a device's
|
|
2102
|
+
* meta row. Mirrors `setPrimaryChildEntityId` but persists the
|
|
2103
|
+
* accordion-section layout array (parent-only). Persisted, projected,
|
|
2104
|
+
* and preserved across re-register/restore.
|
|
2105
|
+
*/
|
|
2106
|
+
setChildLayout: async (input) => {
|
|
2107
|
+
const { deviceId, childLayout } = input;
|
|
2108
|
+
await withMetaWriteLock(async () => {
|
|
2109
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2110
|
+
if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
|
|
2111
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2112
|
+
const key = deviceKey(aid, stableId);
|
|
2113
|
+
const allMeta = await readMeta();
|
|
2114
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2115
|
+
...allMeta,
|
|
2116
|
+
[key]: {
|
|
2117
|
+
...m,
|
|
2118
|
+
childLayout
|
|
2119
|
+
}
|
|
2120
|
+
} });
|
|
2121
|
+
});
|
|
2122
|
+
this.ctx.eventBus.emit({
|
|
2123
|
+
id: randomUUID(),
|
|
2124
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2125
|
+
source: {
|
|
2126
|
+
type: "device",
|
|
2127
|
+
id: deviceId
|
|
2128
|
+
},
|
|
2129
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2130
|
+
data: {
|
|
2131
|
+
deviceId,
|
|
2132
|
+
field: "childLayout",
|
|
2133
|
+
value: childLayout
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
},
|
|
2137
|
+
/**
|
|
2138
|
+
* Set (or replace) the operator-authored cross-device field wirings on
|
|
2139
|
+
* a device's meta row. Mirrors `setChildLayout` but persists the
|
|
2140
|
+
* `deviceLinks` array. Persisted, projected, and preserved across
|
|
2141
|
+
* re-register/restore. Also maintains the in-memory `devicesWithLinks`
|
|
2142
|
+
* Set used as an O(1) gate by `resolveLinkedStatus`.
|
|
2143
|
+
*/
|
|
2144
|
+
setDeviceLinks: async (input) => {
|
|
2145
|
+
const { deviceId, deviceLinks } = input;
|
|
2146
|
+
await withMetaWriteLock(async () => {
|
|
2147
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2148
|
+
if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
|
|
2149
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2150
|
+
const key = deviceKey(aid, stableId);
|
|
2151
|
+
const allMeta = await readMeta();
|
|
2152
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2153
|
+
...allMeta,
|
|
2154
|
+
[key]: {
|
|
2155
|
+
...m,
|
|
2156
|
+
deviceLinks
|
|
2157
|
+
}
|
|
2158
|
+
} });
|
|
2159
|
+
});
|
|
2160
|
+
if (deviceLinks.length > 0) this.devicesWithLinks.add(deviceId);
|
|
2161
|
+
else this.devicesWithLinks.delete(deviceId);
|
|
2162
|
+
await rebuildLinkDependents();
|
|
2163
|
+
this.ctx.eventBus.emit({
|
|
2164
|
+
id: randomUUID(),
|
|
2165
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2166
|
+
source: {
|
|
2167
|
+
type: "device",
|
|
2168
|
+
id: deviceId
|
|
2169
|
+
},
|
|
2170
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2171
|
+
data: {
|
|
2172
|
+
deviceId,
|
|
2173
|
+
field: "deviceLinks",
|
|
2174
|
+
value: deviceLinks
|
|
2175
|
+
}
|
|
2176
|
+
});
|
|
2177
|
+
},
|
|
2178
|
+
/**
|
|
2179
|
+
* Stamp (or update) the semantic role on the device's meta row.
|
|
2180
|
+
* Called by the kernel's `create()` / `spawnAccessoryChild`
|
|
2181
|
+
* pre-seed when `initialMeta.role` is set (analogous to
|
|
2182
|
+
* `setIntegrationId`). Idempotent. `null` clears a previous role.
|
|
2183
|
+
*/
|
|
2184
|
+
setRole: async (input) => {
|
|
2185
|
+
const { deviceId, role } = input;
|
|
2186
|
+
await withMetaWriteLock(async () => {
|
|
2187
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2188
|
+
if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
|
|
2189
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2190
|
+
const key = deviceKey(aid, stableId);
|
|
2191
|
+
const allMeta = await readMeta();
|
|
2192
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2193
|
+
...allMeta,
|
|
2194
|
+
[key]: {
|
|
2195
|
+
...m,
|
|
2196
|
+
role
|
|
2197
|
+
}
|
|
2198
|
+
} });
|
|
2199
|
+
});
|
|
2200
|
+
this.ctx.eventBus.emit({
|
|
2201
|
+
id: randomUUID(),
|
|
2202
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2203
|
+
source: {
|
|
2204
|
+
type: "device",
|
|
2205
|
+
id: deviceId
|
|
2206
|
+
},
|
|
2207
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2208
|
+
data: {
|
|
2209
|
+
deviceId,
|
|
2210
|
+
field: "role",
|
|
2211
|
+
value: role
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
},
|
|
2215
|
+
/**
|
|
2216
|
+
* Batched meta pre-seed. Applies every provided field to the
|
|
2217
|
+
* device's meta row in ONE read-modify-write under a single
|
|
2218
|
+
* `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
|
|
2219
|
+
* then emits one `DeviceMetaChanged` event per field that was
|
|
2220
|
+
* supplied — preserving the exact semantics of the individual
|
|
2221
|
+
* setters (`setName` / `setLocation` / `setType` /
|
|
2222
|
+
* `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
|
|
2223
|
+
* fields are left untouched; `null` clears `location` /
|
|
2224
|
+
* `linkDeviceId` / `role`. Idempotent.
|
|
2225
|
+
*
|
|
2226
|
+
* Collapses the per-child meta pre-seed (up to 6 individual
|
|
2227
|
+
* setter round-trips, each its own lock + write) into one — the
|
|
2228
|
+
* dominant cost when the kernel's `spawnAccessoryChild` reconciles
|
|
2229
|
+
* a many-entity container.
|
|
2230
|
+
*/
|
|
2231
|
+
applyInitialMeta: async (input) => {
|
|
2232
|
+
const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
|
|
2233
|
+
await withMetaWriteLock(async () => {
|
|
2234
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2235
|
+
if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
|
|
2236
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2237
|
+
const key = deviceKey(aid, stableId);
|
|
2238
|
+
const allMeta = await readMeta();
|
|
2239
|
+
const merged = {
|
|
2240
|
+
...m,
|
|
2241
|
+
...name !== void 0 ? { name } : {},
|
|
2242
|
+
...location !== void 0 ? { location } : {},
|
|
2243
|
+
...type !== void 0 ? { type } : {},
|
|
2244
|
+
...integrationId !== void 0 ? { integrationId } : {},
|
|
2245
|
+
...linkDeviceId !== void 0 ? { linkDeviceId } : {},
|
|
2246
|
+
...role !== void 0 ? { role } : {}
|
|
2247
|
+
};
|
|
2248
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2249
|
+
...allMeta,
|
|
2250
|
+
[key]: merged
|
|
2251
|
+
} });
|
|
2252
|
+
});
|
|
2253
|
+
const emitMetaChanged = (field, value) => {
|
|
2254
|
+
this.ctx.eventBus.emit({
|
|
2255
|
+
id: randomUUID(),
|
|
2256
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2257
|
+
source: {
|
|
2258
|
+
type: "device",
|
|
2259
|
+
id: deviceId
|
|
2260
|
+
},
|
|
2261
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2262
|
+
data: {
|
|
2263
|
+
deviceId,
|
|
2264
|
+
field,
|
|
2265
|
+
value
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
};
|
|
2269
|
+
if (name !== void 0) emitMetaChanged("name", name);
|
|
2270
|
+
if (location !== void 0) emitMetaChanged("location", location);
|
|
2271
|
+
if (type !== void 0) emitMetaChanged("type", type);
|
|
2272
|
+
if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
|
|
2273
|
+
if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
|
|
2274
|
+
if (role !== void 0) emitMetaChanged("role", role);
|
|
2275
|
+
},
|
|
2276
|
+
/**
|
|
2277
|
+
* Patch the device's hardware-identity metadata blob. Shallow
|
|
2278
|
+
* merge — `null` removes a key, anything else overwrites.
|
|
2279
|
+
* Drivers populate factual fields on first probe; operators
|
|
2280
|
+
* augment via the Device Info tab. Idempotent: a no-op patch
|
|
2281
|
+
* (every key already present with the same value) doesn't emit
|
|
2282
|
+
* the meta-changed event.
|
|
2283
|
+
*/
|
|
2284
|
+
setMetadata: async (input) => {
|
|
2285
|
+
const { deviceId, patch } = input;
|
|
2286
|
+
const result = await withMetaWriteLock(async () => {
|
|
2287
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2288
|
+
if (!persisted) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
|
|
2289
|
+
const { addonId, stableId } = persisted;
|
|
2290
|
+
const key = deviceKey(addonId, stableId);
|
|
2291
|
+
const map = await readMetadataMap();
|
|
2292
|
+
const next = { ...map[key] ?? {} };
|
|
2293
|
+
let changed = false;
|
|
2294
|
+
for (const [k, v] of Object.entries(patch)) if (v === null) {
|
|
2295
|
+
if (k in next) {
|
|
2296
|
+
delete next[k];
|
|
2297
|
+
changed = true;
|
|
2298
|
+
}
|
|
2299
|
+
} else if (next[k] !== v) {
|
|
2300
|
+
next[k] = v;
|
|
2301
|
+
changed = true;
|
|
2302
|
+
}
|
|
2303
|
+
if (!changed) return { changed: false };
|
|
2304
|
+
const hasFields = Object.keys(next).length > 0;
|
|
2305
|
+
const updatedMap = { ...map };
|
|
2306
|
+
if (hasFields) updatedMap[key] = next;
|
|
2307
|
+
else delete updatedMap[key];
|
|
2308
|
+
await settings.writeAddonStore({ deviceMetadata: updatedMap });
|
|
2309
|
+
return {
|
|
2310
|
+
changed: true,
|
|
2311
|
+
finalMeta: hasFields ? next : null
|
|
2312
|
+
};
|
|
2313
|
+
});
|
|
2314
|
+
if (!result.changed) return;
|
|
2315
|
+
this.ctx.eventBus.emit({
|
|
2316
|
+
id: randomUUID(),
|
|
2317
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2318
|
+
source: {
|
|
2319
|
+
type: "device",
|
|
2320
|
+
id: deviceId
|
|
2321
|
+
},
|
|
2322
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2323
|
+
data: {
|
|
2324
|
+
deviceId,
|
|
2325
|
+
field: "metadata",
|
|
2326
|
+
value: result.finalMeta
|
|
2327
|
+
}
|
|
2328
|
+
});
|
|
2329
|
+
},
|
|
2330
|
+
/**
|
|
2331
|
+
* Soft-disable the device. Persisted on the meta row;
|
|
2332
|
+
* lifecycle gating is the driver's responsibility (BaseDevice
|
|
2333
|
+
* exposes `this.disabled` for the driver to consult at the top
|
|
2334
|
+
* of its lifecycle methods).
|
|
2335
|
+
*/
|
|
2336
|
+
setDisabled: async (input) => {
|
|
2337
|
+
const { deviceId, disabled } = input;
|
|
2338
|
+
await withMetaWriteLock(async () => {
|
|
2339
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2340
|
+
if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
|
|
2341
|
+
const { addonId, stableId, meta: m } = persisted;
|
|
2342
|
+
const key = deviceKey(addonId, stableId);
|
|
2343
|
+
const allMeta = await readMeta();
|
|
2344
|
+
await settings.writeAddonStore({ deviceMeta: {
|
|
2345
|
+
...allMeta,
|
|
2346
|
+
[key]: {
|
|
2347
|
+
...m,
|
|
2348
|
+
disabled
|
|
2349
|
+
}
|
|
2350
|
+
} });
|
|
2351
|
+
});
|
|
2352
|
+
this.ctx.eventBus.emit({
|
|
2353
|
+
id: randomUUID(),
|
|
2354
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2355
|
+
source: {
|
|
2356
|
+
type: "device",
|
|
2357
|
+
id: deviceId
|
|
2358
|
+
},
|
|
2359
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2360
|
+
data: {
|
|
2361
|
+
deviceId,
|
|
2362
|
+
field: "disabled",
|
|
2363
|
+
value: disabled
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2366
|
+
},
|
|
2367
|
+
loadRuntimeState: async (input) => {
|
|
2368
|
+
const { deviceId } = input;
|
|
2369
|
+
if (!await resolvePersistedById(deviceId)) return {};
|
|
2370
|
+
const data = await settings.readDeviceRuntimeState(deviceId);
|
|
2371
|
+
this.seedMirror(deviceId, this.withResetSessionProbe(data));
|
|
2372
|
+
return data;
|
|
2373
|
+
},
|
|
2374
|
+
/**
|
|
2375
|
+
* Union of (1) operator-curated location registry and (2) labels
|
|
2376
|
+
* currently in use on persisted devices. Case-insensitive
|
|
2377
|
+
* dedupe (preserves the first-seen casing). Sorted
|
|
2378
|
+
* case-insensitively for stable UI. Drives the Device Info
|
|
2379
|
+
* location autocomplete.
|
|
2380
|
+
*/
|
|
2381
|
+
listLocations: async () => {
|
|
2382
|
+
const store = await settings.readAddonStore();
|
|
2383
|
+
const meta = store.deviceMeta ?? {};
|
|
2384
|
+
const registry = store.locations ?? [];
|
|
2385
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2386
|
+
const consider = (raw) => {
|
|
2387
|
+
if (typeof raw !== "string") return;
|
|
2388
|
+
const trimmed = raw.trim();
|
|
2389
|
+
if (trimmed.length === 0) return;
|
|
2390
|
+
const key = trimmed.toLowerCase();
|
|
2391
|
+
if (!seen.has(key)) seen.set(key, trimmed);
|
|
2392
|
+
};
|
|
2393
|
+
for (const label of registry) consider(label);
|
|
2394
|
+
for (const m of Object.values(meta)) consider(m.location);
|
|
2395
|
+
return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
|
|
2396
|
+
},
|
|
2397
|
+
/**
|
|
2398
|
+
* Add a label to the curated location registry. Idempotent:
|
|
2399
|
+
* existing entries (case-insensitive match) are silently kept.
|
|
2400
|
+
* Empty / whitespace-only inputs throw — operators must supply a
|
|
2401
|
+
* meaningful label.
|
|
2402
|
+
*/
|
|
2403
|
+
addLocation: async (input) => {
|
|
2404
|
+
const trimmed = input.name.trim();
|
|
2405
|
+
if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
|
|
2406
|
+
const current = (await settings.readAddonStore()).locations ?? [];
|
|
2407
|
+
if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
|
|
2408
|
+
await settings.writeAddonStore({ locations: [...current, trimmed] });
|
|
2409
|
+
},
|
|
2410
|
+
/**
|
|
2411
|
+
* Remove a label from the curated registry. Match is
|
|
2412
|
+
* case-insensitive. Devices that still reference this label keep
|
|
2413
|
+
* their `meta.location` value (the registry is a suggestion
|
|
2414
|
+
* list, not a foreign key) — pass `cascade: true` to also clear
|
|
2415
|
+
* `setLocation` on every device that referenced this exact
|
|
2416
|
+
* label. Cascade only matches case-insensitively + trimmed, same
|
|
2417
|
+
* as the registry equality check.
|
|
2418
|
+
*/
|
|
2419
|
+
removeLocation: async (input) => {
|
|
2420
|
+
const trimmed = input.name.trim();
|
|
2421
|
+
if (trimmed.length === 0) return;
|
|
2422
|
+
const store = await settings.readAddonStore();
|
|
2423
|
+
const current = store.locations ?? [];
|
|
2424
|
+
const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
|
|
2425
|
+
if (remaining.length !== current.length) await settings.writeAddonStore({ locations: remaining });
|
|
2426
|
+
if (input.cascade !== true) return;
|
|
2427
|
+
const meta = store.deviceMeta ?? {};
|
|
2428
|
+
const updates = { ...meta };
|
|
2429
|
+
const cleared = [];
|
|
2430
|
+
for (const [key, m] of Object.entries(meta)) {
|
|
2431
|
+
if (typeof m.location !== "string") continue;
|
|
2432
|
+
if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
|
|
2433
|
+
updates[key] = {
|
|
2434
|
+
...m,
|
|
2435
|
+
location: null
|
|
2436
|
+
};
|
|
2437
|
+
cleared.push(m.id);
|
|
2438
|
+
}
|
|
2439
|
+
if (cleared.length === 0) return;
|
|
2440
|
+
await settings.writeAddonStore({ deviceMeta: updates });
|
|
2441
|
+
for (const deviceId of cleared) this.ctx.eventBus.emit({
|
|
2442
|
+
id: randomUUID(),
|
|
2443
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2444
|
+
source: {
|
|
2445
|
+
type: "device",
|
|
2446
|
+
id: deviceId
|
|
2447
|
+
},
|
|
2448
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2449
|
+
data: {
|
|
2450
|
+
deviceId,
|
|
2451
|
+
field: "location",
|
|
2452
|
+
value: null
|
|
2453
|
+
}
|
|
2454
|
+
});
|
|
2455
|
+
},
|
|
2456
|
+
listPersistedByAddon: async (input) => {
|
|
2457
|
+
const { addonId } = input;
|
|
2458
|
+
const [index, meta] = await Promise.all([readIndex(), readMeta()]);
|
|
2459
|
+
return (index[addonId] ?? []).map((stableId) => {
|
|
2460
|
+
const m = meta[deviceKey(addonId, stableId)];
|
|
2461
|
+
return {
|
|
2462
|
+
id: m.id,
|
|
2463
|
+
stableId,
|
|
2464
|
+
type: m.type,
|
|
2465
|
+
name: m.name,
|
|
2466
|
+
location: m.location ?? null,
|
|
2467
|
+
disabled: m.disabled ?? false,
|
|
2468
|
+
parentDeviceId: m.parentDeviceId
|
|
2469
|
+
};
|
|
2470
|
+
});
|
|
2471
|
+
},
|
|
2472
|
+
listAll: async (input) => {
|
|
2473
|
+
const { addonId } = input;
|
|
2474
|
+
const results = [];
|
|
2475
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2476
|
+
const meta = await readMeta();
|
|
2477
|
+
const metadataMap = await readMetadataMap();
|
|
2478
|
+
if (registry) {
|
|
2479
|
+
const liveEntries = addonId ? registry.getAllForAddon(addonId).map((device) => ({
|
|
2480
|
+
addonId,
|
|
2481
|
+
device
|
|
2482
|
+
})) : registry.getAllWithAddonId();
|
|
2483
|
+
for (const { addonId: aid, device } of liveEntries) {
|
|
2484
|
+
const key = deviceKey(aid, device.stableId);
|
|
2485
|
+
const metadata = metadataMap[key] ?? null;
|
|
2486
|
+
const metaRow = meta[key] ?? null;
|
|
2487
|
+
results.push(toDeviceInfo(aid, device, metadata, metaRow));
|
|
2488
|
+
seen.add(key);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
const index = await readIndex();
|
|
2492
|
+
const targetAddons = addonId ? [addonId] : Object.keys(index);
|
|
2493
|
+
for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
|
|
2494
|
+
const key = deviceKey(aid, stableId);
|
|
2495
|
+
if (seen.has(key)) continue;
|
|
2496
|
+
const m = meta[key];
|
|
2497
|
+
const persistedType = m.type;
|
|
2498
|
+
const persistedConfig = await settings.readDeviceStore(m.id);
|
|
2499
|
+
const metadata = metadataMap[key] ?? null;
|
|
2500
|
+
results.push({
|
|
2501
|
+
id: m.id,
|
|
2502
|
+
stableId,
|
|
2503
|
+
addonId: aid,
|
|
2504
|
+
type: persistedType,
|
|
2505
|
+
name: m?.name ?? stableId,
|
|
2506
|
+
location: m?.location ?? null,
|
|
2507
|
+
disabled: m?.disabled ?? false,
|
|
2508
|
+
parentDeviceId: m?.parentDeviceId ?? null,
|
|
2509
|
+
role: toDeviceRole(m?.role),
|
|
2510
|
+
online: this.resolveDeviceOnline(m.id, registry !== null),
|
|
2511
|
+
probed: this.resolveDeviceProbed(m.id),
|
|
2512
|
+
features: persistedFeatures(m?.features),
|
|
2513
|
+
isCamera: persistedType === DeviceType.Camera,
|
|
2514
|
+
config: persistedConfig ?? {},
|
|
2515
|
+
metadata,
|
|
2516
|
+
...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
|
|
2517
|
+
...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
|
|
2518
|
+
...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2519
|
+
...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2520
|
+
...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2521
|
+
...(() => {
|
|
2522
|
+
const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
|
|
2523
|
+
return si !== void 0 ? { sourceInfo: si } : {};
|
|
2524
|
+
})()
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
return results;
|
|
2528
|
+
},
|
|
2529
|
+
getDevice: async (input) => {
|
|
2530
|
+
const { deviceId } = input;
|
|
2531
|
+
if (registry) {
|
|
2532
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2533
|
+
if (found) {
|
|
2534
|
+
const key = deviceKey(found.addonId, found.device.stableId);
|
|
2535
|
+
const [map, metaMap] = await Promise.all([readMetadataMap(), readMeta()]);
|
|
2536
|
+
const metadata = map[key] ?? null;
|
|
2537
|
+
const metaRow = metaMap[key] ?? null;
|
|
2538
|
+
return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
const persisted = await resolvePersistedById(deviceId);
|
|
2542
|
+
if (!persisted) return null;
|
|
2543
|
+
const { addonId: aid, stableId, meta: m } = persisted;
|
|
2544
|
+
const persistedConfig = await settings.readDeviceStore(m.id);
|
|
2545
|
+
const key = deviceKey(aid, stableId);
|
|
2546
|
+
const metadata = (await readMetadataMap())[key] ?? null;
|
|
2547
|
+
const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
|
|
2548
|
+
return {
|
|
2549
|
+
id: deviceId,
|
|
2550
|
+
stableId,
|
|
2551
|
+
addonId: aid,
|
|
2552
|
+
type: m.type,
|
|
2553
|
+
name: m.name,
|
|
2554
|
+
location: m.location ?? null,
|
|
2555
|
+
disabled: m.disabled ?? false,
|
|
2556
|
+
parentDeviceId: m.parentDeviceId,
|
|
2557
|
+
role: toDeviceRole(m.role),
|
|
2558
|
+
online: this.resolveDeviceOnline(deviceId, true),
|
|
2559
|
+
probed: this.resolveDeviceProbed(deviceId),
|
|
2560
|
+
features: persistedFeatures(m.features),
|
|
2561
|
+
isCamera: false,
|
|
2562
|
+
config: persistedConfig ?? {},
|
|
2563
|
+
metadata,
|
|
2564
|
+
...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
|
|
2565
|
+
...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
|
|
2566
|
+
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2567
|
+
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2568
|
+
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2569
|
+
...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
|
|
2570
|
+
};
|
|
2571
|
+
},
|
|
2572
|
+
getChildren: async (input) => {
|
|
2573
|
+
const { parentDeviceId } = input;
|
|
2574
|
+
let ownerAddonId = null;
|
|
2575
|
+
if (registry) {
|
|
2576
|
+
if (registry.getById(parentDeviceId)) ownerAddonId = registry.getAddonId(parentDeviceId);
|
|
2577
|
+
}
|
|
2578
|
+
if (!ownerAddonId) {
|
|
2579
|
+
const persisted = await resolvePersistedById(parentDeviceId);
|
|
2580
|
+
if (!persisted) return [];
|
|
2581
|
+
ownerAddonId = persisted.addonId;
|
|
2582
|
+
}
|
|
2583
|
+
const results = [];
|
|
2584
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2585
|
+
const [index, meta, metadataMap] = await Promise.all([
|
|
2586
|
+
readIndex(),
|
|
2587
|
+
readMeta(),
|
|
2588
|
+
readMetadataMap()
|
|
2589
|
+
]);
|
|
2590
|
+
if (registry) {
|
|
2591
|
+
const liveChildren = registry.getChildren(parentDeviceId);
|
|
2592
|
+
for (const device of liveChildren) {
|
|
2593
|
+
const key = deviceKey(ownerAddonId, device.stableId);
|
|
2594
|
+
const metadata = metadataMap[key] ?? null;
|
|
2595
|
+
const metaRow = meta[key] ?? null;
|
|
2596
|
+
results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
|
|
2597
|
+
seen.add(key);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => meta[deviceKey(ownerAddonId, sid)]?.parentDeviceId === parentDeviceId);
|
|
2601
|
+
for (const childStableId of persistedChildren) {
|
|
2602
|
+
const key = deviceKey(ownerAddonId, childStableId);
|
|
2603
|
+
if (seen.has(key)) continue;
|
|
2604
|
+
const m = meta[key];
|
|
2605
|
+
const persistedConfig = await settings.readDeviceStore(m.id);
|
|
2606
|
+
const metadata = metadataMap[key] ?? null;
|
|
2607
|
+
const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
|
|
2608
|
+
results.push({
|
|
2609
|
+
id: m.id,
|
|
2610
|
+
stableId: childStableId,
|
|
2611
|
+
addonId: ownerAddonId,
|
|
2612
|
+
type: m.type,
|
|
2613
|
+
name: m.name,
|
|
2614
|
+
location: m.location ?? null,
|
|
2615
|
+
disabled: m.disabled ?? false,
|
|
2616
|
+
parentDeviceId,
|
|
2617
|
+
role: toDeviceRole(m.role),
|
|
2618
|
+
online: this.resolveDeviceOnline(m.id, registry !== null),
|
|
2619
|
+
probed: this.resolveDeviceProbed(m.id),
|
|
2620
|
+
features: persistedFeatures(m.features),
|
|
2621
|
+
isCamera: false,
|
|
2622
|
+
config: persistedConfig ?? {},
|
|
2623
|
+
metadata,
|
|
2624
|
+
...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
|
|
2625
|
+
...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
|
|
2626
|
+
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2627
|
+
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2628
|
+
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2629
|
+
...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
return results;
|
|
2633
|
+
},
|
|
2634
|
+
getStreamSources: async (input) => {
|
|
2635
|
+
const { deviceId } = input;
|
|
2636
|
+
if (registry) {
|
|
2637
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2638
|
+
if (found) {
|
|
2639
|
+
if (!isCameraDevice(found.device)) return [];
|
|
2640
|
+
return (await found.device.getStreamSources()).map((s) => ({
|
|
2641
|
+
id: s.id,
|
|
2642
|
+
label: s.label,
|
|
2643
|
+
protocol: s.protocol,
|
|
2644
|
+
url: s.url,
|
|
2645
|
+
resolution: s.resolution,
|
|
2646
|
+
fps: s.fps,
|
|
2647
|
+
bitrate: s.bitrate,
|
|
2648
|
+
codec: s.codec,
|
|
2649
|
+
profileHint: s.profileHint
|
|
2650
|
+
}));
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
|
|
2654
|
+
return (await requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
|
|
2655
|
+
},
|
|
2656
|
+
getConfigSchema: async (input) => {
|
|
2657
|
+
const { deviceId } = input;
|
|
2658
|
+
if (registry) {
|
|
2659
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2660
|
+
if (found) return found.device.config.entries().map((entry) => ({
|
|
2661
|
+
key: entry.key,
|
|
2662
|
+
value: entry.value,
|
|
2663
|
+
...entry.description !== void 0 ? { description: entry.description } : {}
|
|
2664
|
+
}));
|
|
2665
|
+
}
|
|
2666
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
|
|
2667
|
+
return (await requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
|
|
2668
|
+
},
|
|
2669
|
+
getSettingsSchema: async (input) => {
|
|
2670
|
+
const { deviceId } = input;
|
|
2671
|
+
if (registry) {
|
|
2672
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2673
|
+
if (found) return found.device.getSettingsUISchema();
|
|
2674
|
+
}
|
|
2675
|
+
if (!await resolvePersistedById(deviceId)) return null;
|
|
2676
|
+
return await requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
|
|
2677
|
+
},
|
|
2678
|
+
updateConfig: async (input) => {
|
|
2679
|
+
const { deviceId } = input;
|
|
2680
|
+
if (registry) {
|
|
2681
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2682
|
+
if (found) {
|
|
2683
|
+
await found.device.config.setAll(input.values);
|
|
2684
|
+
return { success: true };
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
|
|
2688
|
+
await requireDeviceOps(deviceId).setConfig({
|
|
2689
|
+
deviceId,
|
|
2690
|
+
values: input.values
|
|
2691
|
+
});
|
|
2692
|
+
return { success: true };
|
|
2693
|
+
},
|
|
2694
|
+
enable: async (input) => {
|
|
2695
|
+
await provider.setDisabled({
|
|
2696
|
+
deviceId: input.deviceId,
|
|
2697
|
+
disabled: false
|
|
2698
|
+
});
|
|
2699
|
+
return { success: true };
|
|
2700
|
+
},
|
|
2701
|
+
disable: async (input) => {
|
|
2702
|
+
await provider.setDisabled({
|
|
2703
|
+
deviceId: input.deviceId,
|
|
2704
|
+
disabled: true
|
|
2705
|
+
});
|
|
2706
|
+
return { success: true };
|
|
2707
|
+
},
|
|
2708
|
+
remove: async (input) => {
|
|
2709
|
+
const { deviceId } = input;
|
|
2710
|
+
const directChildIds = async (parentId) => {
|
|
2711
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2712
|
+
if (registry) for (const c of registry.getChildren(parentId)) ids.add(c.id);
|
|
2713
|
+
const meta = await readMeta();
|
|
2714
|
+
for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
|
|
2715
|
+
ids.delete(parentId);
|
|
2716
|
+
return [...ids];
|
|
2717
|
+
};
|
|
2718
|
+
const removeOne = async (id) => {
|
|
2719
|
+
if (registry) {
|
|
2720
|
+
const live = resolveDeviceById(registry, id);
|
|
2721
|
+
if (live) {
|
|
2722
|
+
const deviceName = live.device.name;
|
|
2723
|
+
await live.device.removeDevice();
|
|
2724
|
+
registry.remove(id);
|
|
2725
|
+
await provider.removeDevice({ deviceId: id });
|
|
2726
|
+
this.ctx.logger.info("removed hub-local device", { tags: {
|
|
2727
|
+
deviceId: id,
|
|
2728
|
+
deviceName
|
|
2729
|
+
} });
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
const persisted = await resolvePersistedById(id);
|
|
2734
|
+
if (!persisted) return;
|
|
2735
|
+
const { meta: persistedMeta } = persisted;
|
|
2736
|
+
try {
|
|
2737
|
+
await requireDeviceOps(id).removeDevice({ deviceId: id });
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
this.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
|
|
2740
|
+
tags: {
|
|
2741
|
+
deviceId: id,
|
|
2742
|
+
deviceName: persistedMeta.name
|
|
2743
|
+
},
|
|
2744
|
+
meta: { error: errMsg(err) }
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
await provider.removeDevice({ deviceId: id });
|
|
2748
|
+
};
|
|
2749
|
+
const removeCascade = async (id) => {
|
|
2750
|
+
for (const childId of await directChildIds(id)) await removeCascade(childId);
|
|
2751
|
+
await removeOne(id);
|
|
2752
|
+
};
|
|
2753
|
+
await removeCascade(deviceId);
|
|
2754
|
+
return { success: true };
|
|
2755
|
+
},
|
|
2756
|
+
/**
|
|
2757
|
+
* Cascade-delete every top-level device whose `integrationId`
|
|
2758
|
+
* matches. Enumerates a SNAPSHOT of the meta map so concurrent
|
|
2759
|
+
* removals don't clobber each other. Only top-level parents are
|
|
2760
|
+
* enumerated — children cascade via the per-parent `removeCascade`
|
|
2761
|
+
* inside the delegated `remove` call. Idempotent: devices with no
|
|
2762
|
+
* `integrationId` never match.
|
|
2763
|
+
*/
|
|
2764
|
+
removeByIntegration: async (input) => {
|
|
2765
|
+
const { integrationId } = input;
|
|
2766
|
+
const meta = await readMeta();
|
|
2767
|
+
const parentKeys = Object.keys(meta).filter((key) => {
|
|
2768
|
+
const m = meta[key];
|
|
2769
|
+
return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
|
|
2770
|
+
});
|
|
2771
|
+
let removed = 0;
|
|
2772
|
+
for (const _key of parentKeys) {
|
|
2773
|
+
const m = meta[_key];
|
|
2774
|
+
if (!m) continue;
|
|
2775
|
+
await provider.remove({ deviceId: m.id });
|
|
2776
|
+
removed++;
|
|
2777
|
+
}
|
|
2778
|
+
return { removed };
|
|
2779
|
+
},
|
|
2780
|
+
getStreamProfileMap: async (input) => {
|
|
2781
|
+
if (!registry) return {};
|
|
2782
|
+
const found = resolveDeviceById(registry, input.deviceId);
|
|
2783
|
+
if (!found) return {};
|
|
2784
|
+
const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
|
|
2785
|
+
if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
|
|
2786
|
+
if (!isCameraDevice(found.device)) return {};
|
|
2787
|
+
const sources = await found.device.getStreamSources();
|
|
2788
|
+
const profileMap = {};
|
|
2789
|
+
for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
|
|
2790
|
+
return profileMap;
|
|
2791
|
+
},
|
|
2792
|
+
setStreamProfileMap: async (input) => {
|
|
2793
|
+
const { deviceId } = input;
|
|
2794
|
+
if (registry) {
|
|
2795
|
+
const found = resolveDeviceById(registry, deviceId);
|
|
2796
|
+
if (found) {
|
|
2797
|
+
await found.device.config.setAll({ _profileMap: input.profileMap });
|
|
2798
|
+
return { success: true };
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
|
|
2802
|
+
await requireDeviceOps(deviceId).setConfig({
|
|
2803
|
+
deviceId,
|
|
2804
|
+
values: { _profileMap: input.profileMap }
|
|
2805
|
+
});
|
|
2806
|
+
return { success: true };
|
|
2807
|
+
},
|
|
2808
|
+
probeStreams: async (input) => {
|
|
2809
|
+
const streamProbe = this.ctx.kernel.streamProbe;
|
|
2810
|
+
if (!streamProbe) return [];
|
|
2811
|
+
const sources = await provider.getStreamSources({ deviceId: input.deviceId });
|
|
2812
|
+
const results = [];
|
|
2813
|
+
for (const s of sources) {
|
|
2814
|
+
if (!s.url) continue;
|
|
2815
|
+
try {
|
|
2816
|
+
const metadata = await streamProbe.probe(s.url, { force: true });
|
|
2817
|
+
results.push({
|
|
2818
|
+
streamId: s.id,
|
|
2819
|
+
width: metadata.width,
|
|
2820
|
+
height: metadata.height,
|
|
2821
|
+
codec: metadata.codec,
|
|
2822
|
+
fps: metadata.fps,
|
|
2823
|
+
bitrateKbps: metadata.bitrateKbps
|
|
2824
|
+
});
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
this.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
|
|
2827
|
+
deviceId: input.deviceId,
|
|
2828
|
+
streamId: s.id,
|
|
2829
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2830
|
+
} });
|
|
2831
|
+
results.push({ streamId: s.id });
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
return results;
|
|
2835
|
+
},
|
|
2836
|
+
discoverDevices: async (input) => {
|
|
2837
|
+
const dp = await this.requireDeviceProvider(input.addonId);
|
|
2838
|
+
if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
|
|
2839
|
+
return (await dp.discoverDevices({})).map((d) => ({
|
|
2840
|
+
stableId: d.stableId,
|
|
2841
|
+
type: d.type,
|
|
2842
|
+
suggestedName: d.suggestedName,
|
|
2843
|
+
prefilledConfig: d.prefilledConfig
|
|
2844
|
+
}));
|
|
2845
|
+
},
|
|
2846
|
+
adoptDevice: async (input) => {
|
|
2847
|
+
const dp = await this.requireDeviceProvider(input.addonId);
|
|
2848
|
+
if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
|
|
2849
|
+
const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
|
|
2850
|
+
if (input.integrationId !== void 0) try {
|
|
2851
|
+
await stampIntegrationId(summary.id, input.integrationId);
|
|
2852
|
+
} catch (err) {
|
|
2853
|
+
this.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
|
|
2854
|
+
tags: {
|
|
2855
|
+
deviceId: summary.id,
|
|
2856
|
+
integrationId: input.integrationId
|
|
2857
|
+
},
|
|
2858
|
+
meta: { error: errMsg(err) }
|
|
2859
|
+
});
|
|
2860
|
+
}
|
|
2861
|
+
return summary;
|
|
2862
|
+
},
|
|
2863
|
+
getCreationSchema: async (input) => {
|
|
2864
|
+
const dp = await this.requireDeviceProvider(input.addonId);
|
|
2865
|
+
if (!await dp.supportsManualCreation({})) return null;
|
|
2866
|
+
return await dp.getChildCreationSchema({ type: input.type }) ?? null;
|
|
2867
|
+
},
|
|
2868
|
+
createDevice: async (input) => {
|
|
2869
|
+
const dp = await this.requireDeviceProvider(input.addonId);
|
|
2870
|
+
if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
|
|
2871
|
+
const summary = await dp.createDevice({
|
|
2872
|
+
type: input.type,
|
|
2873
|
+
config: input.config
|
|
2874
|
+
});
|
|
2875
|
+
if (input.integrationId !== void 0) try {
|
|
2876
|
+
await stampIntegrationId(summary.id, input.integrationId);
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
this.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
|
|
2879
|
+
tags: {
|
|
2880
|
+
deviceId: summary.id,
|
|
2881
|
+
integrationId: input.integrationId
|
|
2882
|
+
},
|
|
2883
|
+
meta: { error: errMsg(err) }
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
return summary;
|
|
2887
|
+
},
|
|
2888
|
+
testCreationField: async (input) => {
|
|
2889
|
+
return (await this.requireDeviceProvider(input.addonId)).testCreationField({
|
|
2890
|
+
type: input.type,
|
|
2891
|
+
key: input.key,
|
|
2892
|
+
value: input.value,
|
|
2893
|
+
...input.formValues !== void 0 ? { formValues: input.formValues } : {}
|
|
2894
|
+
});
|
|
2895
|
+
},
|
|
2896
|
+
adoptionListCandidates: async ({ addonId, ...rest }) => {
|
|
2897
|
+
return (await this.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
|
|
2898
|
+
},
|
|
2899
|
+
adoptionRefresh: async ({ addonId, integrationId }) => {
|
|
2900
|
+
return (await this.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
|
|
2901
|
+
},
|
|
2902
|
+
adoptionAdopt: async ({ addonId, ...rest }) => {
|
|
2903
|
+
return (await this.requireDeviceAdoptionProvider(addonId)).adopt(rest);
|
|
2904
|
+
},
|
|
2905
|
+
adoptionRelease: async ({ addonId, ...rest }) => {
|
|
2906
|
+
return (await this.requireDeviceAdoptionProvider(addonId)).release(rest);
|
|
2907
|
+
},
|
|
2908
|
+
testField: async (input) => {
|
|
2909
|
+
const { deviceId } = input;
|
|
2910
|
+
let owningAddonId = null;
|
|
2911
|
+
if (registry) owningAddonId = registry.getAddonId(deviceId);
|
|
2912
|
+
if (!owningAddonId) owningAddonId = (await resolvePersistedById(deviceId))?.addonId ?? null;
|
|
2913
|
+
if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
|
|
2914
|
+
const dp = await this.waitDeviceProvider(owningAddonId);
|
|
2915
|
+
if (!dp) return {
|
|
2916
|
+
status: "ok",
|
|
2917
|
+
labels: [],
|
|
2918
|
+
error: void 0
|
|
2919
|
+
};
|
|
2920
|
+
if (typeof dp.testCreationField !== "function") return {
|
|
2921
|
+
status: "ok",
|
|
2922
|
+
labels: [],
|
|
2923
|
+
error: void 0
|
|
2924
|
+
};
|
|
2925
|
+
return dp.testCreationField({
|
|
2926
|
+
type: DeviceType.Camera,
|
|
2927
|
+
key: input.key,
|
|
2928
|
+
value: input.value
|
|
2929
|
+
});
|
|
2930
|
+
},
|
|
2931
|
+
getBindings: async (input) => {
|
|
2932
|
+
const result = await this.getBindings({ deviceId: input.deviceId });
|
|
2933
|
+
return {
|
|
2934
|
+
deviceId: input.deviceId,
|
|
2935
|
+
entries: result.entries
|
|
2936
|
+
};
|
|
2937
|
+
},
|
|
2938
|
+
getAllBindings: async () => {
|
|
2939
|
+
return this.getAllBindings();
|
|
2940
|
+
},
|
|
2941
|
+
setWrapperActive: async (input) => {
|
|
2942
|
+
return this.setWrapperActive({
|
|
2943
|
+
deviceId: input.deviceId,
|
|
2944
|
+
capName: input.capName,
|
|
2945
|
+
wrapperAddonId: input.wrapperAddonId,
|
|
2946
|
+
active: input.active
|
|
2947
|
+
});
|
|
2948
|
+
},
|
|
2949
|
+
listWrappersForCap: async (input) => this.listWrappersForCap(input),
|
|
2950
|
+
listBindableCapsForDeviceType: async (input) => this.listBindableCapsForDeviceType(input),
|
|
2951
|
+
getDeviceSettingsAggregate: async (input) => {
|
|
2952
|
+
return this.getDeviceAggregate(input.deviceId, "settings");
|
|
2953
|
+
},
|
|
2954
|
+
getDeviceLiveInfoAggregate: async (input) => {
|
|
2955
|
+
return this.getDeviceAggregate(input.deviceId, "live");
|
|
2956
|
+
},
|
|
2957
|
+
getDeviceAggregate: async (input) => {
|
|
2958
|
+
const [settings, live] = await Promise.all([this.getDeviceAggregate(input.deviceId, "settings"), this.getDeviceAggregate(input.deviceId, "live")]);
|
|
2959
|
+
return {
|
|
2960
|
+
settings,
|
|
2961
|
+
live
|
|
2962
|
+
};
|
|
2963
|
+
},
|
|
2964
|
+
runDeviceAction: async (input) => {
|
|
2965
|
+
return this.dispatchDeviceAction(input.deviceId, input.action, input.input);
|
|
2966
|
+
},
|
|
2967
|
+
updateDeviceField: async (input) => {
|
|
2968
|
+
return this.updateDeviceField({
|
|
2969
|
+
deviceId: input.deviceId,
|
|
2970
|
+
writerCapName: input.writerCapName,
|
|
2971
|
+
writerAddonId: input.writerAddonId,
|
|
2972
|
+
key: input.key,
|
|
2973
|
+
value: input.value
|
|
2974
|
+
});
|
|
2975
|
+
},
|
|
2976
|
+
updateDeviceFieldsBatch: async (input) => {
|
|
2977
|
+
return this.updateDeviceFieldsBatch({
|
|
2978
|
+
deviceId: input.deviceId,
|
|
2979
|
+
changes: input.changes
|
|
2980
|
+
});
|
|
2981
|
+
},
|
|
2982
|
+
getDeviceStatusAggregate: async (input) => this.getDeviceStatusAggregate(input),
|
|
2983
|
+
getWireableFields: async ({ deviceId }) => {
|
|
2984
|
+
const reg = this.capabilityRegistry;
|
|
2985
|
+
if (!reg) return { caps: [] };
|
|
2986
|
+
const { entries } = await this.getBindings({ deviceId });
|
|
2987
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2988
|
+
const caps = [];
|
|
2989
|
+
for (const entry of entries) {
|
|
2990
|
+
if (seen.has(entry.capName)) continue;
|
|
2991
|
+
seen.add(entry.capName);
|
|
2992
|
+
const def = reg.getDefinition(entry.capName);
|
|
2993
|
+
if (!def || def.kind === "wrapper") continue;
|
|
2994
|
+
const schema = def.status?.schema;
|
|
2995
|
+
if (!schema) continue;
|
|
2996
|
+
const fields = enumerateSchemaFields(schema).map((f) => f.enumValues !== void 0 ? {
|
|
2997
|
+
path: f.path,
|
|
2998
|
+
kind: f.kind,
|
|
2999
|
+
enumValues: [...f.enumValues]
|
|
3000
|
+
} : {
|
|
3001
|
+
path: f.path,
|
|
3002
|
+
kind: f.kind
|
|
3003
|
+
});
|
|
3004
|
+
if (fields.length > 0) caps.push({
|
|
3005
|
+
cap: entry.capName,
|
|
3006
|
+
fields
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
return { caps };
|
|
3010
|
+
}
|
|
3011
|
+
};
|
|
3012
|
+
this.ctx.logger.info("registered device-manager capability", { meta: { liveRegistry: registry !== null } });
|
|
3013
|
+
if (registry) {
|
|
3014
|
+
this.propagator = new DeviceEventPropagator({
|
|
3015
|
+
eventBus: this.ctx.eventBus,
|
|
3016
|
+
getParentOf: (id) => registry.getById(id)?.parentDeviceId ?? null,
|
|
3017
|
+
logger: {
|
|
3018
|
+
warn: (msg, meta) => this.ctx.logger.warn(msg, meta ?? {}),
|
|
3019
|
+
debug: (msg, meta) => this.ctx.logger.debug(msg, meta ?? {})
|
|
3020
|
+
}
|
|
3021
|
+
});
|
|
3022
|
+
this.propagator.start();
|
|
3023
|
+
this.ctx.logger.info("device-event-propagator started");
|
|
3024
|
+
}
|
|
3025
|
+
return [{
|
|
3026
|
+
capability: deviceManagerCapability,
|
|
3027
|
+
provider
|
|
3028
|
+
}, {
|
|
3029
|
+
capability: deviceStateCapability,
|
|
3030
|
+
provider: {
|
|
3031
|
+
getSnapshot: async (input) => {
|
|
3032
|
+
return this.snapshotForDeviceOverlayed(input.deviceId);
|
|
3033
|
+
},
|
|
3034
|
+
getCapSlice: async (input) => {
|
|
3035
|
+
return this.overlayedSlice(input.deviceId, input.capName);
|
|
3036
|
+
},
|
|
3037
|
+
getAllSnapshots: async () => {
|
|
3038
|
+
const out = {};
|
|
3039
|
+
for (const [deviceId, perCap] of this.stateMirror) {
|
|
3040
|
+
const dev = {};
|
|
3041
|
+
for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
|
|
3042
|
+
out[String(deviceId)] = dev;
|
|
3043
|
+
}
|
|
3044
|
+
return out;
|
|
3045
|
+
},
|
|
3046
|
+
setCapSlice: async (input) => {
|
|
3047
|
+
const { deviceId, capName, slice } = input;
|
|
3048
|
+
if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
|
|
3049
|
+
this.applySingleCapUpdate(deviceId, capName, slice);
|
|
3050
|
+
this.scheduleRuntimeStateDiskWrite(deviceId, settings);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
}];
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Single-cap mirror update — diff against the current mirror,
|
|
3057
|
+
* persist the new slice in-memory, emit `DeviceStateChanged` for
|
|
3058
|
+
* this cap. No-op on identical writes (both same shape and same
|
|
3059
|
+
* values). Called by `setCapSlice` provider.
|
|
3060
|
+
*/
|
|
3061
|
+
applySingleCapUpdate(deviceId, capName, slice) {
|
|
3062
|
+
let perCap = this.stateMirror.get(deviceId);
|
|
3063
|
+
if (!perCap) {
|
|
3064
|
+
perCap = /* @__PURE__ */ new Map();
|
|
3065
|
+
this.stateMirror.set(deviceId, perCap);
|
|
3066
|
+
}
|
|
3067
|
+
const prior = perCap.get(capName);
|
|
3068
|
+
if (prior && shallowEqual(prior, slice)) return;
|
|
3069
|
+
perCap.set(capName, { ...slice });
|
|
3070
|
+
this.emitOverlayed(deviceId, capName);
|
|
3071
|
+
const deps = this.linkDependents.get(`${deviceId}:${capName}`);
|
|
3072
|
+
if (deps) for (const d of deps) this.emitOverlayed(d.targetDeviceId, d.targetCap);
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* Debounced disk writer. Coalesces frequent writes (motion phase
|
|
3076
|
+
* transitions, battery pushes) into one `writeDeviceRuntimeState`
|
|
3077
|
+
* per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
|
|
3078
|
+
* blob from the live mirror at flush time so the disk picture is
|
|
3079
|
+
* always the latest state — no risk of writing a stale snapshot.
|
|
3080
|
+
*/
|
|
3081
|
+
scheduleRuntimeStateDiskWrite(deviceId, settings) {
|
|
3082
|
+
let slot = this.runtimeStateDebounce.get(deviceId);
|
|
3083
|
+
if (!slot) {
|
|
3084
|
+
slot = {
|
|
3085
|
+
timer: null,
|
|
3086
|
+
inFlight: null
|
|
3087
|
+
};
|
|
3088
|
+
this.runtimeStateDebounce.set(deviceId, slot);
|
|
3089
|
+
}
|
|
3090
|
+
if (slot.timer) return;
|
|
3091
|
+
slot.timer = setTimeout(() => {
|
|
3092
|
+
slot.timer = null;
|
|
3093
|
+
const blob = this.snapshotForDevice(deviceId);
|
|
3094
|
+
const write = (async () => {
|
|
3095
|
+
try {
|
|
3096
|
+
await settings.writeDeviceRuntimeState(deviceId, blob);
|
|
3097
|
+
} catch (err) {
|
|
3098
|
+
this.ctx.logger.warn("writeDeviceRuntimeState failed", {
|
|
3099
|
+
tags: { deviceId },
|
|
3100
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
3101
|
+
});
|
|
3102
|
+
} finally {
|
|
3103
|
+
slot.inFlight = null;
|
|
3104
|
+
}
|
|
3105
|
+
})();
|
|
3106
|
+
slot.inFlight = write;
|
|
3107
|
+
}, DeviceManagerAddon.RUNTIME_STATE_DEBOUNCE_MS);
|
|
3108
|
+
}
|
|
3109
|
+
/**
|
|
3110
|
+
* One-shot mirror seed used by `loadRuntimeState` at boot so the
|
|
3111
|
+
* hub knows about every persisted slice without waiting for the
|
|
3112
|
+
* first `setCapSlice` call. No events emitted — this is
|
|
3113
|
+
* initial-state population, not a transition.
|
|
3114
|
+
*
|
|
3115
|
+
* Callers that must not carry a stale per-session probe across a
|
|
3116
|
+
* restart pass the blob through `withResetSessionProbe` first (see
|
|
3117
|
+
* `loadRuntimeState`).
|
|
3118
|
+
*/
|
|
3119
|
+
seedMirror(deviceId, blob) {
|
|
3120
|
+
let perCap = this.stateMirror.get(deviceId);
|
|
3121
|
+
if (!perCap) {
|
|
3122
|
+
perCap = /* @__PURE__ */ new Map();
|
|
3123
|
+
this.stateMirror.set(deviceId, perCap);
|
|
3124
|
+
}
|
|
3125
|
+
for (const [capName, raw] of Object.entries(blob)) {
|
|
3126
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
3127
|
+
perCap.set(capName, { ...raw });
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
/**
|
|
3131
|
+
* The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
|
|
3132
|
+
* signal — it means "this worker process completed a probe THIS session".
|
|
3133
|
+
* Persisted runtime state carries the PRE-RESTART timestamp, which is stale
|
|
3134
|
+
* after a hub or worker restart: the device has not re-probed yet. Seeding it
|
|
3135
|
+
* verbatim makes `resolveDeviceProbed` report `probed:true` during the
|
|
3136
|
+
* restart→reprobe window, which defeats the export carry-forward gate
|
|
3137
|
+
* (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
|
|
3138
|
+
* partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
|
|
3139
|
+
*
|
|
3140
|
+
* Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
|
|
3141
|
+
* the last-advertised fingerprint forward until the worker republishes a
|
|
3142
|
+
* fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
|
|
3143
|
+
* also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
|
|
3144
|
+
* is untouched, and every non-probe slice (e.g. `device-status`/online) is
|
|
3145
|
+
* preserved.
|
|
3146
|
+
*/
|
|
3147
|
+
withResetSessionProbe(blob) {
|
|
3148
|
+
const probe = blob["feature-probe"];
|
|
3149
|
+
if (!probe || typeof probe !== "object" || Array.isArray(probe)) return blob;
|
|
3150
|
+
return {
|
|
3151
|
+
...blob,
|
|
3152
|
+
"feature-probe": {
|
|
3153
|
+
...probe,
|
|
3154
|
+
lastProbedAt: 0
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Resolve a device's REAL `online` flag for the persisted/forked-worker
|
|
3160
|
+
* list branch. Forked workers own the live `IDevice` in their own process,
|
|
3161
|
+
* so the hub registry can't read `device.online` directly. The owning
|
|
3162
|
+
* driver instead publishes its liveness through the auto-registered
|
|
3163
|
+
* `device-status` runtime-state slice (`markOnline` → `setCapState`), which
|
|
3164
|
+
* the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
|
|
3165
|
+
* hub-side `stateMirror`. We read that mirrored slice here so the list
|
|
3166
|
+
* payload reflects the device's actual reachability instead of a constant.
|
|
3167
|
+
*
|
|
3168
|
+
* Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
|
|
3169
|
+
* has been published yet: a persisted device with a live registry is
|
|
3170
|
+
* assumed online (it was successfully registered by its owning process),
|
|
3171
|
+
* and the null-registry "offline view" keeps reporting offline. We never
|
|
3172
|
+
* regress a device to offline merely because its mirror is empty.
|
|
3173
|
+
*/
|
|
3174
|
+
resolveDeviceOnline(deviceId, fallbackOnline) {
|
|
3175
|
+
const raw = this.stateMirror.get(deviceId)?.get(deviceStatusCapability.name);
|
|
3176
|
+
if (!raw) return fallbackOnline;
|
|
3177
|
+
const parsed = DeviceStatusSchema.safeParse(raw);
|
|
3178
|
+
if (!parsed.success) return fallbackOnline;
|
|
3179
|
+
return parsed.data.online;
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* Derive the `probed` flag for an offline-view (forked-worker, not
|
|
3183
|
+
* live in the hub registry) device projection. Reads the mirrored
|
|
3184
|
+
* `feature-probe` slice the owning worker publishes. Mirrors the
|
|
3185
|
+
* `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
|
|
3186
|
+
* slice present → ready iff `lastProbedAt` has advanced past 0. The
|
|
3187
|
+
* mirror is populated when the worker's first `setCapSlice` RPC arrives
|
|
3188
|
+
* (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
|
|
3189
|
+
* cross-process delivery is async); until then the no-entry path returns
|
|
3190
|
+
* `true` — a brief transient window, same as `resolveDeviceOnline`.
|
|
3191
|
+
*/
|
|
3192
|
+
resolveDeviceProbed(deviceId) {
|
|
3193
|
+
const raw = this.stateMirror.get(deviceId)?.get("feature-probe");
|
|
3194
|
+
if (!raw) return true;
|
|
3195
|
+
return (typeof raw.lastProbedAt === "number" ? raw.lastProbedAt : 0) > 0;
|
|
3196
|
+
}
|
|
3197
|
+
snapshotForDevice(deviceId) {
|
|
3198
|
+
const perCap = this.stateMirror.get(deviceId);
|
|
3199
|
+
if (!perCap) return {};
|
|
3200
|
+
const out = {};
|
|
3201
|
+
for (const [k, v] of perCap) out[k] = { ...v };
|
|
3202
|
+
return out;
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
3206
|
+
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
3207
|
+
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
3208
|
+
* cross-process call. The disk writer must NOT use this method; it must
|
|
3209
|
+
* persist raw provider truth via snapshotForDevice.
|
|
3210
|
+
*/
|
|
3211
|
+
overlayedSlice(deviceId, cap) {
|
|
3212
|
+
const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
|
|
3213
|
+
const links = this.linkTargets.get(`${deviceId}:${cap}`);
|
|
3214
|
+
if (!links || links.length === 0) return raw ? { ...raw } : null;
|
|
3215
|
+
const resolved = links.map((rl) => ({
|
|
3216
|
+
link: rl.link,
|
|
3217
|
+
sourceValue: applyTransform(getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
|
|
3218
|
+
}));
|
|
3219
|
+
const schema = this.capabilityRegistry?.getDefinition(cap)?.status?.schema;
|
|
3220
|
+
return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
|
|
3221
|
+
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
3224
|
+
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
3225
|
+
* getAllSnapshots) so callers see overlayed values. The debounced disk
|
|
3226
|
+
* writer must continue to call snapshotForDevice (raw truth).
|
|
3227
|
+
*/
|
|
3228
|
+
snapshotForDeviceOverlayed(deviceId) {
|
|
3229
|
+
const perCap = this.stateMirror.get(deviceId);
|
|
3230
|
+
if (!perCap) return {};
|
|
3231
|
+
const out = {};
|
|
3232
|
+
for (const capName of perCap.keys()) {
|
|
3233
|
+
const s = this.overlayedSlice(deviceId, capName);
|
|
3234
|
+
if (s) out[capName] = s;
|
|
3235
|
+
}
|
|
3236
|
+
return out;
|
|
3237
|
+
}
|
|
3238
|
+
emitStateChanged(deviceId, capName, slice) {
|
|
3239
|
+
this.ctx.eventBus.emit({
|
|
3240
|
+
id: randomUUID(),
|
|
3241
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3242
|
+
source: {
|
|
3243
|
+
type: "device",
|
|
3244
|
+
id: deviceId
|
|
3245
|
+
},
|
|
3246
|
+
category: EventCategory.DeviceStateChanged,
|
|
3247
|
+
data: {
|
|
3248
|
+
deviceId,
|
|
3249
|
+
capName,
|
|
3250
|
+
slice
|
|
3251
|
+
}
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
/** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
|
|
3255
|
+
* skipping when the overlay is unchanged since the last emit (loop/churn
|
|
3256
|
+
* guard). Used for the written pair AND its dependent targets. */
|
|
3257
|
+
emitOverlayed(deviceId, cap) {
|
|
3258
|
+
const slice = this.overlayedSlice(deviceId, cap);
|
|
3259
|
+
if (!slice) return;
|
|
3260
|
+
const key = `${deviceId}:${cap}`;
|
|
3261
|
+
const prev = this.lastEmittedOverlay.get(key);
|
|
3262
|
+
if (prev && shallowEqual(prev, slice)) return;
|
|
3263
|
+
this.lastEmittedOverlay.set(key, slice);
|
|
3264
|
+
this.emitStateChanged(deviceId, cap, slice);
|
|
3265
|
+
}
|
|
3266
|
+
async onShutdown() {
|
|
3267
|
+
this.propagator?.stop();
|
|
3268
|
+
this.propagator = null;
|
|
3269
|
+
const settings = this.ctx.settings;
|
|
3270
|
+
const pending = [];
|
|
3271
|
+
for (const [deviceId, slot] of this.runtimeStateDebounce) {
|
|
3272
|
+
if (slot.timer) {
|
|
3273
|
+
clearTimeout(slot.timer);
|
|
3274
|
+
slot.timer = null;
|
|
3275
|
+
if (settings) {
|
|
3276
|
+
const blob = this.snapshotForDevice(deviceId);
|
|
3277
|
+
pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
|
|
3278
|
+
this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
|
|
3279
|
+
tags: { deviceId },
|
|
3280
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
3281
|
+
});
|
|
3282
|
+
}));
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
if (slot.inFlight) pending.push(slot.inFlight);
|
|
3286
|
+
}
|
|
3287
|
+
await Promise.all(pending);
|
|
3288
|
+
this.runtimeStateDebounce.clear();
|
|
3289
|
+
}
|
|
3290
|
+
};
|
|
3291
|
+
//#endregion
|
|
3292
|
+
export { DeviceManagerAddon, DeviceManagerAddon as default, mergeAggregates };
|