@camstack/system 1.1.52 → 1.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/addon-utils.d.ts +1 -0
  4. package/dist/addon-utils.js +2 -0
  5. package/dist/addon-utils.mjs +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  10. package/dist/builtins/alerts/alerts.addon.js +1 -1
  11. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/device-manager/device-linked-devices.d.ts +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +1608 -1589
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +1608 -1589
  19. package/dist/builtins/device-manager/device-store-sections.d.ts +26 -0
  20. package/dist/builtins/doorbell/doorbell-settings.d.ts +58 -0
  21. package/dist/builtins/doorbell/trigger-engine.d.ts +32 -30
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +39 -22
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +207 -191
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +207 -191
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  32. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  33. package/dist/builtins/platform-probe/index.js +1 -1
  34. package/dist/builtins/platform-probe/index.mjs +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  37. package/dist/builtins/snapshot/index.js +1 -1
  38. package/dist/builtins/snapshot/index.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +11 -3
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +11 -3
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +1 -1
  46. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{dist-BduBhv6N.js → dist-B5C4dVvO.js} +293 -23
  50. package/dist/{dist-B_PSvr-m.mjs → dist-DssImRC5.mjs} +293 -23
  51. package/dist/index.js +22 -5
  52. package/dist/index.mjs +22 -6
  53. package/dist/kernel/index.d.ts +1 -1
  54. package/dist/kernel/moleculer/readiness-context.d.ts +12 -8
  55. package/dist/kernel/moleculer/readiness-service.d.ts +16 -1
  56. package/dist/{manifest-python-deps-CIyvuHAB.mjs → manifest-python-deps-CWom-eqp.mjs} +35 -9
  57. package/dist/{manifest-python-deps-CPAMH5gV.js → manifest-python-deps-kkvku9yU.js} +35 -9
  58. package/package.json +2 -2
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BduBhv6N.js");
6
+ const require_dist = require("../../dist-B5C4dVvO.js");
7
7
  let _camstack_types_node = require("@camstack/types/node");
8
8
  let zod = require("zod");
9
9
  let node_crypto = require("node:crypto");
@@ -675,1732 +675,1751 @@ function resolveDeviceById(registry, deviceId) {
675
675
  };
676
676
  }
677
677
  //#endregion
678
- //#region src/builtins/device-manager/device-bindings-store.ts
678
+ //#region src/builtins/device-manager/device-queries.ts
679
679
  /**
680
- * Persisted wrapper-activation + per-device binding resolution for the
681
- * device-manager addon. Extracted from `device-manager.addon.ts` as free
682
- * functions that receive an explicit `BindingsDeps` context (dependency
683
- * injection) instead of closing over the addon instance keeps the routing
684
- * logic cohesive and independently testable while preserving the EXACT call
685
- * semantics, ordering, and side effects of the original class methods.
680
+ * Device query + live-runtime + provider/adoption operations for the
681
+ * device-manager addon the read/live half of the `IDeviceManagerProvider`
682
+ * surface. Extracted verbatim from the `onInitialize` provider literal as free
683
+ * functions taking a `ProviderContext` (DI). Input/return types are pinned to
684
+ * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
685
+ * `ReturnType<…>` so the typed surface is identical to the inferred methods.
686
686
  *
687
- * The addon keeps thin delegator methods (`getBindings`, `getAllBindings`, …)
688
- * that build `BindingsDeps` from its own state and forward here, so every
689
- * existing internal + cap-router call site is unchanged.
690
- */
691
- /**
692
- * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
693
- * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
694
- * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
695
- * full cluster view. Events from the local node are ignored: hub-local natives
696
- * live in `capabilityRegistry` and are folded in directly by getBindings.
687
+ * Covers: persisted/live device queries (listPersistedByAddon, listAll,
688
+ * getDevice, getChildren), live runtime methods (getStreamSources,
689
+ * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
690
+ * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
691
+ * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
692
+ * createDevice, testCreationField), and device-adoption ops
693
+ * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
697
694
  *
698
- * Push events are accurate in the steady state but can be lost during the
699
- * Moleculer transport handshake window (hub restart, crash-respawn,
700
- * restartAddon). The reliable replacement for lost events is the D3 re-handshake
701
- * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
702
- * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
703
- * handler purges a gone node's entries; the worker re-handshakes (and re-emits
704
- * `native-registered`) on its next boot.
695
+ * The hub-live-then-persisted-fallback union, the cross-process device-ops
696
+ * dispatch, the depth-first remove cascade, and the resetToSource resync purge
697
+ * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
698
+ * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
699
+ * `pctx.provider`. Pure refactor no behavior change.
705
700
  */
706
- function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
707
- const localNodeId = ctx.kernel.localNodeId ?? "hub";
708
- ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (event) => {
709
- const { deviceId, capName, reason, addonId, nodeId } = event.data;
710
- if (nodeId === localNodeId) return;
711
- if (reason === "native-registered") {
712
- let perDevice = remoteNativeCaps.get(deviceId);
713
- if (!perDevice) {
714
- perDevice = /* @__PURE__ */ new Map();
715
- remoteNativeCaps.set(deviceId, perDevice);
716
- }
717
- perDevice.set(capName, {
718
- addonId,
719
- nodeId
720
- });
721
- } else if (reason === "native-unregistered") {
722
- const perDevice = remoteNativeCaps.get(deviceId);
723
- if (!perDevice) return;
724
- perDevice.delete(capName);
725
- if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
726
- }
727
- });
728
- const cluster = ctx.kernel.cluster;
729
- if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
730
- const gone = payload.node.id;
731
- const emptyDevices = [];
732
- for (const [deviceId, perDevice] of remoteNativeCaps) {
733
- const toDelete = [];
734
- for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
735
- for (const capName of toDelete) perDevice.delete(capName);
736
- if (perDevice.size === 0) emptyDevices.push(deviceId);
737
- }
738
- for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
701
+ async function listPersistedByAddon(pctx, input) {
702
+ const { addonId } = input;
703
+ const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
704
+ const stableIds = index[addonId] ?? [];
705
+ const byStableId = /* @__PURE__ */ new Map();
706
+ for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
707
+ return stableIds.map((stableId) => {
708
+ const m = byStableId.get(stableId);
709
+ return {
710
+ id: m.id,
711
+ stableId,
712
+ type: m.type,
713
+ name: m.name,
714
+ location: m.location ?? null,
715
+ disabled: m.disabled ?? false,
716
+ parentDeviceId: m.parentDeviceId
717
+ };
739
718
  });
740
719
  }
741
- async function readBindingsStore(deps) {
742
- return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
743
- }
744
- async function writeBindingsStore(deps, next) {
745
- await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
746
- }
747
- function resolveWrapperNodeId(_wrapperAddonId) {
748
- return "hub";
749
- }
750
- /**
751
- * Reduce a provider node id to the routable form `DeviceProxy` can pin.
752
- *
753
- * Every addon runs in its own `addon-runner` with the composite node id
754
- * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) e.g.
755
- * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
756
- * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
757
- * only THROUGH its parent (the hub resolves a hub-local-uds child by
758
- * cap+device; an agent forwards to its own child). `DeviceProxy` pins
759
- * `entry.providerNodeId` on every cap call, so a binding entry must expose the
760
- * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
761
- * to an unknown node → `no-provider`, which surfaces as
762
- * "this camera doesn't expose …" for client-proxy-driven widget caps
763
- * (motion-zones, privacy-mask). Wrappers already report the parent via
764
- * `resolveWrapperNodeId`; this aligns natives with the same contract.
765
- *
766
- * A flat node id (a genuine standalone node with no `/`) is returned
767
- * unchanged.
768
- */
769
- function toRoutableProviderNodeId(nodeId) {
770
- const slash = nodeId.indexOf("/");
771
- return slash === -1 ? nodeId : nodeId.slice(0, slash);
772
- }
773
- /**
774
- * Resolve a remote native cap entry for a given `(capName, deviceId)` by
775
- * consulting the handshake-fed `HubNodeRegistry` via
776
- * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
777
- * `remoteNativeCaps` cache misses — covers the Moleculer transport
778
- * handshake window where `DeviceBindingsChanged` events were lost but the
779
- * D3 re-handshake (post device restore) has already populated the registry.
780
- *
781
- * Returns `null` when the entry is genuinely not present in the cluster
782
- * view (cap not registered on any worker for that device).
783
- */
784
- function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
785
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
786
- if (!clusterCaps) return null;
787
- for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
788
- addonId: entry.addonId,
789
- nodeId: entry.nodeId
790
- };
791
- return null;
792
- }
793
- async function getBindings(deps, input) {
794
- const storeKey = String(input.deviceId);
795
- const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
796
- const entries = [];
797
- const seenCaps = /* @__PURE__ */ new Set();
798
- const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
799
- for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
800
- const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
801
- const remoteNative = resolveRemote(capName);
802
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
803
- const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
804
- if (wrapperAddonId === null && !nativeAddonId) {
805
- seenCaps.add(capName);
806
- continue;
720
+ async function listAll(pctx, input) {
721
+ const { addonId } = input;
722
+ const results = [];
723
+ const seen = /* @__PURE__ */ new Set();
724
+ const meta = await pctx.metaStore.readMeta();
725
+ const metadataMap = await pctx.metaStore.readMetadataMap();
726
+ if (pctx.registry) {
727
+ const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
728
+ addonId,
729
+ device
730
+ })) : pctx.registry.getAllWithAddonId();
731
+ for (const { addonId: aid, device } of liveEntries) {
732
+ const key = String(device.id);
733
+ const metadata = metadataMap[key] ?? null;
734
+ const metaRow = meta[key] ?? null;
735
+ results.push(toDeviceInfo(aid, device, metadata, metaRow));
736
+ seen.add(key);
807
737
  }
808
- entries.push({
809
- capName,
810
- kind: wrapperAddonId ? "wrapped" : "native",
811
- providerAddonId: wrapperAddonId ?? nativeAddonId,
812
- providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
813
- nativeAddonId
814
- });
815
- seenCaps.add(capName);
816
- }
817
- if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
818
- if (seenCaps.has(capName)) continue;
819
- const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
820
- if (!defaultWrapperAddonId) continue;
821
- const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
822
- const remoteNative = resolveRemote(capName);
823
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
824
- entries.push({
825
- capName,
826
- kind: "wrapped",
827
- providerAddonId: defaultWrapperAddonId,
828
- providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
829
- nativeAddonId
830
- });
831
- seenCaps.add(capName);
832
738
  }
833
- if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
834
- if (seenCaps.has(capName)) continue;
835
- const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
836
- entries.push({
837
- capName,
838
- kind: "native",
839
- providerAddonId: nativeAddonId,
840
- providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
841
- nativeAddonId
739
+ const index = await pctx.metaStore.readIndex();
740
+ const metaByAddonStable = /* @__PURE__ */ new Map();
741
+ for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
742
+ const targetAddons = addonId ? [addonId] : Object.keys(index);
743
+ for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
744
+ const m = metaByAddonStable.get(`${aid}${stableId}`);
745
+ const key = String(m.id);
746
+ if (seen.has(key)) continue;
747
+ const persistedType = m.type;
748
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
749
+ const metadata = metadataMap[key] ?? null;
750
+ results.push({
751
+ id: m.id,
752
+ stableId,
753
+ addonId: aid,
754
+ type: persistedType,
755
+ name: m?.name ?? stableId,
756
+ location: m?.location ?? null,
757
+ disabled: m?.disabled ?? false,
758
+ parentDeviceId: m?.parentDeviceId ?? null,
759
+ role: toDeviceRole(m?.role),
760
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
761
+ probed: pctx.host.resolveDeviceProbed(m.id),
762
+ features: persistedFeatures(m?.features),
763
+ isCamera: persistedType === require_dist.DeviceType.Camera,
764
+ config: persistedConfig ?? {},
765
+ metadata,
766
+ ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
767
+ ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
768
+ ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
769
+ ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
770
+ ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
771
+ ...m?.display !== void 0 ? { display: m.display } : {},
772
+ ...(() => {
773
+ const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
774
+ return si !== void 0 ? { sourceInfo: si } : {};
775
+ })()
842
776
  });
843
- seenCaps.add(capName);
844
777
  }
845
- const pushFed = deps.remoteNativeCaps.get(input.deviceId);
846
- if (pushFed) for (const [capName, info] of pushFed) {
847
- if (seenCaps.has(capName)) continue;
848
- entries.push({
849
- capName,
850
- kind: "native",
851
- providerAddonId: info.addonId,
852
- providerNodeId: toRoutableProviderNodeId(info.nodeId),
853
- nativeAddonId: info.addonId
854
- });
855
- seenCaps.add(capName);
778
+ return results;
779
+ }
780
+ async function getDevice(pctx, input) {
781
+ const { deviceId } = input;
782
+ if (pctx.registry) {
783
+ const found = resolveDeviceById(pctx.registry, deviceId);
784
+ if (found) {
785
+ const key = String(found.device.id);
786
+ const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
787
+ const metadata = map[key] ?? null;
788
+ const metaRow = metaMap[key] ?? null;
789
+ return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
790
+ }
856
791
  }
857
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
858
- if (clusterCaps) for (const entry of clusterCaps) {
859
- if (entry.deviceId !== input.deviceId) continue;
860
- if (seenCaps.has(entry.capName)) continue;
861
- if (!entry.addonId) continue;
862
- const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
863
- if (entry.nodeId === localNodeId) continue;
864
- entries.push({
865
- capName: entry.capName,
866
- kind: "native",
867
- providerAddonId: entry.addonId,
868
- providerNodeId: toRoutableProviderNodeId(entry.nodeId),
869
- nativeAddonId: entry.addonId
792
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
793
+ if (!persisted) return null;
794
+ const { addonId: aid, stableId, meta: m } = persisted;
795
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
796
+ const key = String(deviceId);
797
+ const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
798
+ const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
799
+ return {
800
+ id: deviceId,
801
+ stableId,
802
+ addonId: aid,
803
+ type: m.type,
804
+ name: m.name,
805
+ location: m.location ?? null,
806
+ disabled: m.disabled ?? false,
807
+ parentDeviceId: m.parentDeviceId,
808
+ role: toDeviceRole(m.role),
809
+ online: pctx.host.resolveDeviceOnline(deviceId, true),
810
+ probed: pctx.host.resolveDeviceProbed(deviceId),
811
+ features: persistedFeatures(m.features),
812
+ isCamera: false,
813
+ config: persistedConfig ?? {},
814
+ metadata,
815
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
816
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
817
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
818
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
819
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
820
+ ...m.display !== void 0 ? { display: m.display } : {},
821
+ ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
822
+ };
823
+ }
824
+ async function getChildren(pctx, input) {
825
+ const { parentDeviceId } = input;
826
+ let ownerAddonId = null;
827
+ if (pctx.registry) {
828
+ if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
829
+ }
830
+ if (!ownerAddonId) {
831
+ const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
832
+ if (!persisted) return [];
833
+ ownerAddonId = persisted.addonId;
834
+ }
835
+ const results = [];
836
+ const seen = /* @__PURE__ */ new Set();
837
+ const [index, meta, metadataMap] = await Promise.all([
838
+ pctx.metaStore.readIndex(),
839
+ pctx.metaStore.readMeta(),
840
+ pctx.metaStore.readMetadataMap()
841
+ ]);
842
+ if (pctx.registry) {
843
+ const liveChildren = pctx.registry.getChildren(parentDeviceId);
844
+ for (const device of liveChildren) {
845
+ const key = String(device.id);
846
+ const metadata = metadataMap[key] ?? null;
847
+ const metaRow = meta[key] ?? null;
848
+ results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
849
+ seen.add(key);
850
+ }
851
+ }
852
+ const ownerMetaByStableId = /* @__PURE__ */ new Map();
853
+ for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
854
+ const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
855
+ for (const childStableId of persistedChildren) {
856
+ const m = ownerMetaByStableId.get(childStableId);
857
+ const key = String(m.id);
858
+ if (seen.has(key)) continue;
859
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
860
+ const metadata = metadataMap[key] ?? null;
861
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
862
+ results.push({
863
+ id: m.id,
864
+ stableId: childStableId,
865
+ addonId: ownerAddonId,
866
+ type: m.type,
867
+ name: m.name,
868
+ location: m.location ?? null,
869
+ disabled: m.disabled ?? false,
870
+ parentDeviceId,
871
+ role: toDeviceRole(m.role),
872
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
873
+ probed: pctx.host.resolveDeviceProbed(m.id),
874
+ features: persistedFeatures(m.features),
875
+ isCamera: false,
876
+ config: persistedConfig ?? {},
877
+ metadata,
878
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
879
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
880
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
881
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
882
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
883
+ ...m.display !== void 0 ? { display: m.display } : {},
884
+ ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
870
885
  });
871
- seenCaps.add(entry.capName);
872
886
  }
873
- if (deps.devicesWithLinks?.has(input.deviceId)) {
874
- const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
875
- for (const link of row?.deviceLinks ?? []) {
876
- const capName = link.target.cap;
877
- if (seenCaps.has(capName)) continue;
878
- entries.push({
879
- capName,
880
- kind: "linked",
881
- providerAddonId: deps.ctx.id,
882
- providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
883
- nativeAddonId: ""
884
- });
885
- seenCaps.add(capName);
887
+ return results;
888
+ }
889
+ async function getStreamSources(pctx, input) {
890
+ const { deviceId } = input;
891
+ if (pctx.registry) {
892
+ const found = resolveDeviceById(pctx.registry, deviceId);
893
+ if (found) {
894
+ if (!isCameraDevice(found.device)) return [];
895
+ return (await found.device.getStreamSources()).map((s) => ({
896
+ id: s.id,
897
+ label: s.label,
898
+ protocol: s.protocol,
899
+ url: s.url,
900
+ resolution: s.resolution,
901
+ fps: s.fps,
902
+ bitrate: s.bitrate,
903
+ codec: s.codec,
904
+ profileHint: s.profileHint
905
+ }));
886
906
  }
887
907
  }
888
- return {
889
- deviceId: input.deviceId,
890
- entries
891
- };
908
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
909
+ return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
892
910
  }
893
- /**
894
- * Whole-fleet binding dump. Iterates every device known to the
895
- * deviceRegistry and reuses the per-device `getBindings` resolver
896
- * for each — same routing rules, single round-trip. Used by
897
- * `SystemManager.init()` for warm-boot.
898
- *
899
- * Bindings change rarely (wrapper toggle, device add/remove) so
900
- * clients invalidate via the existing
901
- * `capability.binding-changed` event rather than re-fetching this
902
- * payload periodically.
903
- */
904
- async function getAllBindings(deps) {
905
- const hubRegistry = deps.ctx.kernel?.deviceRegistry;
906
- if (!hubRegistry) return [];
907
- const out = [];
908
- for (const device of hubRegistry.getAll()) out.push(await getBindings(deps, { deviceId: device.id }));
909
- return out;
911
+ async function getConfigSchema(pctx, input) {
912
+ const { deviceId } = input;
913
+ if (pctx.registry) {
914
+ const found = resolveDeviceById(pctx.registry, deviceId);
915
+ if (found) return found.device.config.entries().map((entry) => ({
916
+ key: entry.key,
917
+ value: entry.value,
918
+ ...entry.description !== void 0 ? { description: entry.description } : {}
919
+ }));
920
+ }
921
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
922
+ return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
910
923
  }
911
- /**
912
- * Resolve a numeric deviceId to a stableId via persisted meta.
913
- * Used only by the device-identity section of the device-details
914
- * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
915
- * a readonly display field. All runtime/registry lookups are keyed by
916
- * numeric deviceId; this helper is display-only.
917
- */
918
- async function lookupPersistedStableId(deps, deviceId) {
919
- return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
924
+ async function getSettingsSchema(pctx, input) {
925
+ const { deviceId } = input;
926
+ if (pctx.registry) {
927
+ const found = resolveDeviceById(pctx.registry, deviceId);
928
+ if (found) return found.device.getSettingsUISchema();
929
+ }
930
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
931
+ return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
920
932
  }
921
- //#endregion
922
- //#region src/builtins/device-manager/device-aggregation.ts
923
- /**
924
- * Device-details aggregation + field-update routing for the device-manager
925
- * addon. Extracted from `device-manager.addon.ts` as free functions that
926
- * receive an explicit `AggregationDeps` context (DI) rather than closing over
927
- * the addon instance.
928
- *
929
- * This module owns the binding-driven settings/live/status aggregators
930
- * (`getDeviceAggregate`, `collectSystemDeviceContributions`,
931
- * `deriveDeviceConfigContribution`, `buildBaseDeviceSection`,
932
- * `getDeviceStatusAggregate`, `resolveDriverConfigSchema`) and the
933
- * field-update routing (`updateDeviceField`, `updateDeviceFieldsBatch`,
934
- * `applyGroupPatch`, `resolveContributionProvider`, `dispatchDeviceAction`)
935
- * plus the wrapper-activation mutators (`setWrapperActive`,
936
- * `listWrappersForCap`, `listBindableCapsForDeviceType`).
937
- *
938
- * Sibling aggregator functions call each other directly (passing `deps`);
939
- * cross-domain leaf calls (`getBindings`, `lookupPersistedStableId`,
940
- * `resolveRemoteNativeCapFromRegistry`, read/writeBindingsStore) route through
941
- * the bindings-store module via `deps.bindingsDeps`. EXACT semantics, ordering,
942
- * and side effects of the original class methods are preserved; the addon keeps
943
- * thin delegator methods over these functions.
944
- */
945
- async function getDeviceAggregate(deps, deviceId, kind) {
946
- const registry = deps.capabilityRegistry;
947
- if (!registry) {
948
- deps.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
949
- return null;
933
+ async function updateConfig(pctx, input) {
934
+ const { deviceId } = input;
935
+ if (pctx.registry) {
936
+ const found = resolveDeviceById(pctx.registry, deviceId);
937
+ if (found) {
938
+ await found.device.config.setAll(input.values);
939
+ return { success: true };
940
+ }
950
941
  }
951
- const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
952
- const { entries: bindingEntries } = await getBindings(deps.bindingsDeps, { deviceId });
953
- const seenCaps = new Set(bindingEntries.map((e) => e.capName));
954
- const results = await Promise.all(bindingEntries.map(async (entry) => {
955
- const def = registry.getDefinition(entry.capName);
956
- if (require_dist.isDeviceConfigCap(def)) return deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind);
957
- if (!def?.exposesDeviceSettings) return null;
958
- const provider = registry.getProviderByAddon(entry.capName, entry.providerAddonId);
959
- if (!provider) return null;
960
- try {
961
- const contribution = await provider[method]({ deviceId });
962
- if (!contribution) return null;
963
- return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
942
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
943
+ await pctx.requireDeviceOps(deviceId).setConfig({
944
+ deviceId,
945
+ values: input.values
946
+ });
947
+ return { success: true };
948
+ }
949
+ async function enable(pctx, input) {
950
+ await pctx.provider.setDisabled({
951
+ deviceId: input.deviceId,
952
+ disabled: false
953
+ });
954
+ return { success: true };
955
+ }
956
+ async function disable(pctx, input) {
957
+ await pctx.provider.setDisabled({
958
+ deviceId: input.deviceId,
959
+ disabled: true
960
+ });
961
+ return { success: true };
962
+ }
963
+ async function remove(pctx, input) {
964
+ const { deviceId } = input;
965
+ const removeOne = async (id) => {
966
+ if (pctx.registry) {
967
+ const live = resolveDeviceById(pctx.registry, id);
968
+ if (live) {
969
+ const deviceName = live.device.name;
970
+ await live.device.removeDevice();
971
+ pctx.registry.remove(id);
972
+ await pctx.provider.removeDevice({ deviceId: id });
973
+ pctx.host.ctx.logger.info("removed hub-local device", { tags: {
974
+ deviceId: id,
975
+ deviceName
976
+ } });
977
+ return;
978
+ }
979
+ }
980
+ const persisted = await pctx.metaStore.resolvePersistedById(id);
981
+ if (!persisted) return;
982
+ const { meta: persistedMeta } = persisted;
983
+ try {
984
+ await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
964
985
  } catch (err) {
965
- const msg = err instanceof Error ? err.message : String(err);
966
- deps.ctx.logger.warn("contribution method failed", {
986
+ pctx.host.ctx.logger.warn("remove via device-ops failed clearing persistence anyway", {
967
987
  tags: {
968
- deviceId,
969
- addonId: entry.providerAddonId
988
+ deviceId: id,
989
+ deviceName: persistedMeta.name
970
990
  },
971
- meta: {
972
- capName: entry.capName,
973
- method,
974
- error: msg
975
- }
991
+ meta: { error: require_dist.errMsg(err) }
976
992
  });
977
- return null;
978
993
  }
979
- }));
980
- const base = kind === "settings" ? await buildBaseDeviceSection(deps, deviceId) : null;
981
- const systemResults = await collectSystemDeviceContributions(deps, deviceId, kind, seenCaps);
982
- const parts = [
983
- ...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [],
984
- ...results.filter((r) => r !== null),
985
- ...systemResults
986
- ];
987
- if (parts.length === 0) return null;
988
- return mergeAggregates(parts);
994
+ await pctx.provider.removeDevice({ deviceId: id });
995
+ };
996
+ const removeCascade = async (id) => {
997
+ for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
998
+ await removeOne(id);
999
+ };
1000
+ await removeCascade(deviceId);
1001
+ return { success: true };
989
1002
  }
990
1003
  /**
991
- * System-scoped per-device contributions — the deliberate exception to
992
- * binding-driven aggregation (D12).
993
- *
994
- * A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
995
- * state but is NOT bound per-device: it is a system addon, not a BaseDevice,
996
- * so it never calls `registerNativeCap` and never appears in
997
- * `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
998
- * per-device panel never renders. Two such caps ship today:
999
- * - `stream-broker` (singleton) — the RTSP-restream / pre-buffer settings
1000
- * tab, scoped to the cameras whose streams it serves.
1001
- * - `device-export` (collection) — the "Export" panel; a device can be
1002
- * exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
1003
- *
1004
- * D12 bans fanning a per-device call out to every same-named provider of a
1005
- * `scope: 'device'` cap, because vendor providers SHARE cap names
1006
- * (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
1007
- * vendor-shared — a system cap name maps to one active provider (singleton)
1008
- * or one declared collection — so consulting the cap's own provider(s)
1009
- * directly is safe. Each provider gates internally by deviceId and returns
1010
- * null for devices it does not own.
1011
- *
1012
- * `seenCaps` skips any system cap that already contributed via a binding so
1013
- * its section is never duplicated.
1004
+ * Cascade-delete every top-level device whose `integrationId`
1005
+ * matches. Enumerates a SNAPSHOT of the meta map so concurrent
1006
+ * removals don't clobber each other. Only top-level parents are
1007
+ * enumerated children cascade via the per-parent `removeCascade`
1008
+ * inside the delegated `remove` call. Idempotent: devices with no
1009
+ * `integrationId` never match.
1014
1010
  */
1015
- async function collectSystemDeviceContributions(deps, deviceId, kind, seenCaps) {
1016
- const registry = deps.capabilityRegistry;
1017
- if (!registry) return [];
1018
- const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
1019
- const tasks = [];
1020
- for (const info of registry.listCapabilities()) {
1021
- const def = registry.getDefinition(info.name);
1022
- if (!def || def.scope !== "system" || !def.exposesDeviceSettings) continue;
1023
- if (seenCaps.has(info.name)) continue;
1024
- const addonIds = def.mode === "collection" ? info.providers : info.activeProvider ? [info.activeProvider] : [];
1025
- for (const addonId of addonIds) tasks.push((async () => {
1026
- const provider = registry.getProviderByAddon(info.name, addonId);
1027
- if (!provider) return null;
1028
- try {
1029
- const contribution = await provider[method]({ deviceId });
1030
- if (!contribution) return null;
1031
- return tagContribution(toWireShape(contribution), info.name, addonId, kind);
1032
- } catch (err) {
1033
- deps.ctx.logger.warn("system device-settings contribution skipped", {
1034
- tags: {
1035
- deviceId,
1036
- addonId
1037
- },
1038
- meta: {
1039
- capName: info.name,
1040
- method,
1041
- error: err instanceof Error ? err.message : String(err)
1042
- }
1043
- });
1044
- return null;
1045
- }
1046
- })());
1011
+ async function removeByIntegration(pctx, input) {
1012
+ const { integrationId } = input;
1013
+ const meta = await pctx.metaStore.readMeta();
1014
+ const parentKeys = Object.keys(meta).filter((key) => {
1015
+ const m = meta[key];
1016
+ return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
1017
+ });
1018
+ let removed = 0;
1019
+ for (const _key of parentKeys) {
1020
+ const m = meta[_key];
1021
+ if (!m) continue;
1022
+ await pctx.provider.remove({ deviceId: m.id });
1023
+ removed++;
1047
1024
  }
1048
- return (await Promise.all(tasks)).filter((r) => r !== null);
1025
+ return { removed };
1049
1026
  }
1050
- /**
1051
- * D14: framework-derived device-config contribution.
1052
- *
1053
- * `kind === 'live'` — device-config caps contribute nothing to the live
1054
- * aggregate (they hold editable config, not live observables).
1055
- *
1056
- * `ui.kind === 'widget'` — emits a single structural `type:'widget'`
1057
- * section; the widget self-persists via the cap's own mutations.
1058
- *
1059
- * `ui.kind === 'derived-form'` calls `getOptions`/`getStatus` on the
1060
- * bound provider, runs the registered pure builder, and returns the
1061
- * derived form sections. Returns null when the camera exposes nothing
1062
- * configurable or the provider is not yet registered.
1063
- */
1064
- async function deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind) {
1065
- if (kind === "live") return null;
1066
- const ui = def.deviceConfig.ui;
1067
- if (ui.kind === "widget") return tagContribution({ sections: [{
1068
- id: `${entry.capName}-widget`,
1069
- title: ui.label,
1070
- tab: ui.tab,
1071
- order: ui.order ?? 0,
1072
- ...ui.topTab ? { location: "top-tab" } : {},
1073
- fields: [{
1074
- type: "widget",
1075
- key: `${entry.capName}Widget`,
1076
- label: ui.label,
1077
- widgetId: ui.widgetId
1078
- }]
1079
- }] }, entry.capName, entry.providerAddonId, kind);
1080
- const provider = registry.getProviderForDevice(entry.capName, deviceId);
1081
- if (!provider || typeof provider.getOptions !== "function") return null;
1082
- try {
1083
- const options = await provider.getOptions({ deviceId });
1084
- const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
1085
- const derived = deriveFormContribution(ui.builderId, options, status ?? null);
1086
- if (!derived) return null;
1087
- return tagContribution({
1088
- ...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
1089
- sections: derived.sections.map((s) => ({
1090
- id: s.id,
1091
- title: s.title,
1092
- ...s.tab !== void 0 ? { tab: s.tab } : {},
1093
- ...s.order !== void 0 ? { order: s.order } : {},
1094
- ...s.description !== void 0 ? { description: s.description } : {},
1095
- ...s.columns !== void 0 ? { columns: s.columns } : {},
1096
- fields: [...s.fields]
1097
- }))
1098
- }, entry.capName, entry.providerAddonId, kind);
1099
- } catch (err) {
1100
- const msg = err instanceof Error ? err.message : String(err);
1101
- deps.ctx.logger.warn("device-config derivation failed", {
1102
- tags: {
1103
- deviceId,
1104
- addonId: entry.providerAddonId
1105
- },
1106
- meta: {
1107
- capName: entry.capName,
1108
- error: msg
1109
- }
1110
- });
1111
- return null;
1027
+ async function getStreamProfileMap(pctx, input) {
1028
+ if (!pctx.registry) return {};
1029
+ const found = resolveDeviceById(pctx.registry, input.deviceId);
1030
+ if (!found) return {};
1031
+ const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
1032
+ if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
1033
+ if (!isCameraDevice(found.device)) return {};
1034
+ const sources = await found.device.getStreamSources();
1035
+ const profileMap = {};
1036
+ for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
1037
+ return profileMap;
1038
+ }
1039
+ async function setStreamProfileMap(pctx, input) {
1040
+ const { deviceId } = input;
1041
+ if (pctx.registry) {
1042
+ const found = resolveDeviceById(pctx.registry, deviceId);
1043
+ if (found) {
1044
+ await found.device.config.setAll({ _profileMap: input.profileMap });
1045
+ return { success: true };
1046
+ }
1112
1047
  }
1048
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1049
+ await pctx.requireDeviceOps(deviceId).setConfig({
1050
+ deviceId,
1051
+ values: { _profileMap: input.profileMap }
1052
+ });
1053
+ return { success: true };
1113
1054
  }
1114
- /**
1115
- * Build the device-manager's own contribution to the aggregator — the
1116
- * device identity (id, stableId, addonId, type, online) + the
1117
- * driver-specific config exposed by the device class via
1118
- * `zodEntriesToConfigUI`.
1119
- *
1120
- * Two paths, deliberately symmetric with `getSettingsSchema`:
1121
- *
1122
- * - Hub-local: device's IDevice instance lives in this process'
1123
- * DeviceRegistry, we read config + schema directly by reference.
1124
- * - Cross-process: device lives in a forked worker (RtspCamera on
1125
- * provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
1126
- * `device-ops.getSettingsSchema` native provider for a wire-
1127
- * serializable ConfigUISchema and merge it in under the same
1128
- * "Driver Config" section, so the UI sees the same shape regardless
1129
- * of where the IDevice physically runs.
1130
- *
1131
- * Returns `null` only when the device genuinely doesn't exist anywhere
1132
- * (no hub-local, no persisted ownership, no device-ops native). The
1133
- * aggregator falls back to contributor sections only in that case.
1134
- */
1135
- async function buildBaseDeviceSection(deps, deviceId) {
1136
- const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1137
- const hubLocal = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1138
- const stableId = hubLocal?.device.stableId ?? await lookupPersistedStableId(deps.bindingsDeps, deviceId);
1139
- const nativeOwner = resolveNativeDeviceOwner(deps, deviceId);
1140
- const addonId = hubLocal?.addonId ?? nativeOwner?.addonId ?? null;
1141
- if (!hubLocal && !nativeOwner) return null;
1142
- const sections = [{
1143
- id: "device-identity",
1144
- title: "Identity",
1145
- tab: "general",
1146
- order: 0,
1147
- fields: [
1148
- {
1149
- type: "text",
1150
- key: "_deviceId",
1151
- label: "Device ID",
1152
- readonlyField: true,
1153
- value: String(deviceId)
1154
- },
1155
- {
1156
- type: "text",
1157
- key: "_stableId",
1158
- label: "Stable ID",
1159
- readonlyField: true,
1160
- value: stableId ?? ""
1161
- },
1162
- {
1163
- type: "text",
1164
- key: "_addonId",
1165
- label: "Driver",
1166
- readonlyField: true,
1167
- value: addonId ?? "unknown"
1055
+ async function probeStreams(pctx, input) {
1056
+ const streamProbe = pctx.host.ctx.kernel.streamProbe;
1057
+ if (!streamProbe) return [];
1058
+ const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
1059
+ const results = [];
1060
+ for (const s of sources) {
1061
+ if (!s.url) continue;
1062
+ try {
1063
+ const metadata = await streamProbe.probe(s.url, { force: true });
1064
+ results.push({
1065
+ streamId: s.id,
1066
+ width: metadata.width,
1067
+ height: metadata.height,
1068
+ codec: metadata.codec,
1069
+ fps: metadata.fps,
1070
+ bitrateKbps: metadata.bitrateKbps
1071
+ });
1072
+ } catch (err) {
1073
+ pctx.host.ctx.logger.debug("streamProbe.probe failed returning placeholder", { meta: {
1074
+ deviceId: input.deviceId,
1075
+ streamId: s.id,
1076
+ error: err instanceof Error ? err.message : String(err)
1077
+ } });
1078
+ results.push({ streamId: s.id });
1079
+ }
1080
+ }
1081
+ return results;
1082
+ }
1083
+ async function discoverDevices(pctx, input) {
1084
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1085
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
1086
+ return (await dp.discoverDevices({})).map((d) => ({
1087
+ stableId: d.stableId,
1088
+ type: d.type,
1089
+ suggestedName: d.suggestedName,
1090
+ prefilledConfig: d.prefilledConfig
1091
+ }));
1092
+ }
1093
+ async function adoptDevice(pctx, input) {
1094
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1095
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
1096
+ const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
1097
+ if (input.integrationId !== void 0) try {
1098
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
1099
+ } catch (err) {
1100
+ pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
1101
+ tags: {
1102
+ deviceId: summary.id,
1103
+ integrationId: input.integrationId
1168
1104
  },
1169
- ...hubLocal ? [{
1170
- type: "text",
1171
- key: "_type",
1172
- label: "Type",
1173
- readonlyField: true,
1174
- value: hubLocal.device.type
1175
- }, {
1176
- type: "text",
1177
- key: "_online",
1178
- label: "Online",
1179
- readonlyField: true,
1180
- value: hubLocal.device.online ? "yes" : "no"
1181
- }] : []
1182
- ]
1183
- }];
1184
- const driverResult = await resolveDriverConfigSchema(deps, deviceId, hubLocal);
1185
- if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
1186
- id: section.id,
1187
- title: section.title,
1188
- tab: section.tab ?? "general",
1189
- order: section.order ?? 1,
1190
- fields: [...section.fields],
1191
- ...section.description !== void 0 ? { description: section.description } : {},
1192
- ...section.columns !== void 0 ? { columns: section.columns } : {}
1105
+ meta: { error: require_dist.errMsg(err) }
1106
+ });
1107
+ }
1108
+ return summary;
1109
+ }
1110
+ async function getCreationSchema(pctx, input) {
1111
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1112
+ if (!await dp.supportsManualCreation({})) return null;
1113
+ return await dp.getChildCreationSchema({ type: input.type }) ?? null;
1114
+ }
1115
+ async function createDevice(pctx, input) {
1116
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1117
+ if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
1118
+ const summary = await dp.createDevice({
1119
+ type: input.type,
1120
+ config: input.config
1193
1121
  });
1194
- else if (driverResult.status === "unavailable") sections.push({
1195
- id: "driver-settings-unavailable",
1196
- title: "Driver Settings",
1197
- tab: "general",
1198
- order: 1,
1199
- fields: [{
1200
- type: "info",
1201
- key: "driver-settings-unavailable-notice",
1202
- label: "Temporarily unavailable",
1203
- content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
1204
- variant: "warning"
1205
- }]
1122
+ if (input.integrationId !== void 0) try {
1123
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
1124
+ } catch (err) {
1125
+ pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
1126
+ tags: {
1127
+ deviceId: summary.id,
1128
+ integrationId: input.integrationId
1129
+ },
1130
+ meta: { error: require_dist.errMsg(err) }
1131
+ });
1132
+ }
1133
+ return summary;
1134
+ }
1135
+ async function testCreationField(pctx, input) {
1136
+ return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
1137
+ type: input.type,
1138
+ key: input.key,
1139
+ value: input.value,
1140
+ ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
1206
1141
  });
1142
+ }
1143
+ async function adoptionListCandidates(pctx, input) {
1144
+ const { addonId, ...rest } = input;
1145
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
1146
+ }
1147
+ async function adoptionRefresh(pctx, input) {
1148
+ const { addonId, integrationId } = input;
1149
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
1150
+ }
1151
+ async function adoptionAdopt(pctx, input) {
1152
+ const { addonId, ...rest } = input;
1153
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
1154
+ }
1155
+ async function adoptionRelease(pctx, input) {
1156
+ const { addonId, ...rest } = input;
1157
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
1158
+ }
1159
+ async function adoptionResync(pctx, input) {
1160
+ const { camDeviceId, resetToSource } = input;
1161
+ let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
1162
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
1163
+ if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
1164
+ let removedChildren = 0;
1165
+ if (resetToSource === true) {
1166
+ const childIds = await pctx.metaStore.directChildIds(camDeviceId);
1167
+ for (const childId of childIds) {
1168
+ await pctx.provider.remove({ deviceId: childId });
1169
+ removedChildren += 1;
1170
+ }
1171
+ pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
1172
+ deviceId: camDeviceId,
1173
+ removedChildren
1174
+ } });
1175
+ }
1207
1176
  return {
1208
- sections,
1209
- ...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
1177
+ ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
1178
+ camDeviceId,
1179
+ resetToSource
1180
+ }),
1181
+ removedChildren
1210
1182
  };
1211
1183
  }
1212
- /**
1213
- * Lookup the native owner for `device-ops` on `deviceId` the native-cap
1214
- * registry (hub-local and remote) is keyed by numeric id.
1215
- */
1216
- function resolveNativeDeviceOwner(deps, deviceId) {
1217
- const local = deps.capabilityRegistry?.getNativeAddonId("device-ops", deviceId) ?? null;
1218
- if (local) return {
1219
- addonId: local,
1220
- nodeId: deps.ctx.kernel.localNodeId ?? "hub"
1184
+ async function testField(pctx, input) {
1185
+ const { deviceId } = input;
1186
+ let owningAddonId = null;
1187
+ if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
1188
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
1189
+ if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
1190
+ const dp = await pctx.host.waitDeviceProvider(owningAddonId);
1191
+ if (!dp) return {
1192
+ status: "ok",
1193
+ labels: [],
1194
+ error: void 0
1195
+ };
1196
+ if (typeof dp.testCreationField !== "function") return {
1197
+ status: "ok",
1198
+ labels: [],
1199
+ error: void 0
1200
+ };
1201
+ return dp.testCreationField({
1202
+ type: require_dist.DeviceType.Camera,
1203
+ key: input.key,
1204
+ value: input.value
1205
+ });
1206
+ }
1207
+ //#endregion
1208
+ //#region src/builtins/device-manager/device-linked-devices.ts
1209
+ /** Reserved device-config keys (cf. `_profileMap`). */
1210
+ var LINKED_MODE_CONFIG_KEY = "_linkedDevicesMode";
1211
+ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
1212
+ /** Parse the two reserved keys out of a device-config blob. Tolerant:
1213
+ * ids may arrive as numbers or numeric strings (the multiselect field
1214
+ * stores string option values); anything else is dropped. */
1215
+ function parseLinkedDevicesConfig(blob) {
1216
+ const mode = blob["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1217
+ const rawIds = blob[LINKED_IDS_CONFIG_KEY];
1218
+ const manualIds = [];
1219
+ if (Array.isArray(rawIds)) for (const v of rawIds) {
1220
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1221
+ if (Number.isInteger(n) && !manualIds.includes(n)) manualIds.push(n);
1222
+ }
1223
+ return {
1224
+ mode,
1225
+ manualIds
1221
1226
  };
1222
- const remote = deps.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? resolveRemoteNativeCapFromRegistry(deps.bindingsDeps, "device-ops", deviceId);
1223
- return remote ? {
1224
- addonId: remote.addonId,
1225
- nodeId: remote.nodeId
1226
- } : null;
1227
1227
  }
1228
1228
  /**
1229
- * Aggregate `status` across every registered cap for a device.
1230
- *
1231
- * Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
1232
- * omitted), looks up a native provider per cap via the capability
1233
- * registry, calls `provider.getStatus({ deviceId })`, and validates
1234
- * the return against the cap's own `status.schema`. Validation
1235
- * failures log a warning and yield `null` for that cap so the
1236
- * overall aggregate stays usable — a single misbehaving provider
1237
- * must not blank out a device's entire status view.
1238
- *
1239
- * Returned shape is `Record<capName, unknown | null>`; the client-
1240
- * side hook tightens this to `CapStatusTypeMap` via the generated
1241
- * `cap-status-types.ts`.
1229
+ * Pure resolution core: children (auto same-location | manual → list),
1230
+ * minus self, deduped. Order: children first, then mode additions.
1242
1231
  */
1243
- async function getDeviceStatusAggregate(deps, input) {
1244
- const capNames = input.caps ?? require_dist.CAP_NAMES_WITH_STATUS;
1245
- const registry = deps.capabilityRegistry;
1246
- const out = {};
1247
- if (!registry) {
1248
- for (const name of capNames) out[name] = null;
1249
- return out;
1250
- }
1251
- await Promise.all(capNames.map(async (capName) => {
1252
- try {
1253
- const def = registry.getDefinition(capName);
1254
- if (!def?.status) {
1255
- out[capName] = null;
1256
- return;
1257
- }
1258
- const provider = registry.getProviderForDevice(capName, input.deviceId);
1259
- if (!provider || typeof provider.getStatus !== "function") {
1260
- out[capName] = null;
1261
- return;
1262
- }
1263
- const raw = await provider.getStatus({ deviceId: input.deviceId });
1264
- if (raw == null) {
1265
- out[capName] = null;
1266
- return;
1267
- }
1268
- const parsed = def.status.schema.safeParse(raw);
1269
- if (!parsed.success) {
1270
- deps.ctx.logger.warn("getDeviceStatusAggregate: provider returned invalid status, dropping", {
1271
- tags: { deviceId: input.deviceId },
1272
- meta: {
1273
- capName,
1274
- issues: parsed.error.issues.slice(0, 3)
1275
- }
1276
- });
1277
- out[capName] = null;
1278
- return;
1279
- }
1280
- out[capName] = parsed.data;
1281
- } catch (err) {
1282
- deps.ctx.logger.warn("getDeviceStatusAggregate: provider threw, dropping", {
1283
- tags: { deviceId: input.deviceId },
1284
- meta: {
1285
- capName,
1286
- error: require_dist.errMsg(err)
1287
- }
1288
- });
1289
- out[capName] = null;
1232
+ function resolveLinkedDeviceIds(params) {
1233
+ const { selfId, selfLocation, config, candidates } = params;
1234
+ const ids = /* @__PURE__ */ new Set();
1235
+ for (const c of candidates) if (c.parentDeviceId === selfId) ids.add(c.id);
1236
+ if (config.mode === "auto") {
1237
+ if (selfLocation !== null && selfLocation.length > 0) {
1238
+ for (const c of candidates) if (c.location === selfLocation) ids.add(c.id);
1290
1239
  }
1291
- }));
1292
- return out;
1240
+ } else {
1241
+ const known = new Set(candidates.map((c) => c.id));
1242
+ for (const id of config.manualIds) if (known.has(id)) ids.add(id);
1243
+ }
1244
+ ids.delete(selfId);
1245
+ return [...ids];
1246
+ }
1247
+ /** Cap implementation of `getLinkedDevices`. */
1248
+ async function getLinkedDevices(pctx, input) {
1249
+ const { deviceId } = input;
1250
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1251
+ const config = parseLinkedDevicesConfig(blob);
1252
+ const byId = new Map(all.map((d) => [d.id, d]));
1253
+ const ids = resolveLinkedDeviceIds({
1254
+ selfId: deviceId,
1255
+ selfLocation: byId.get(deviceId)?.location ?? null,
1256
+ config,
1257
+ candidates: all
1258
+ });
1259
+ const devices = [];
1260
+ for (const id of ids) {
1261
+ const d = byId.get(id);
1262
+ if (!d) continue;
1263
+ devices.push({
1264
+ deviceId: d.id,
1265
+ name: d.name,
1266
+ location: d.location,
1267
+ features: d.features
1268
+ });
1269
+ }
1270
+ return {
1271
+ mode: config.mode,
1272
+ devices
1273
+ };
1293
1274
  }
1294
1275
  /**
1295
- * Return the driver-specific device-settings contribution. Hub-local
1296
- * devices call `getSettingsUISchema()` directly; forked-worker devices
1297
- * go through the `device-ops.getSettingsSchema` cap method on the
1298
- * numeric-id-keyed native registry.
1299
- *
1300
- * Returns a discriminated result so callers can distinguish three states:
1301
- * 'ok' – schema obtained successfully
1302
- * 'none' – driver genuinely has no settings schema
1303
- * 'unavailable' – worker was unreachable after retries (transient)
1276
+ * "Linked devices" settings section for a CAMERA (null for every other
1277
+ * device type the association is camera-anchored). Contributed through
1278
+ * `collectSystemDeviceContributions`; saves route through the existing
1279
+ * `writerCapName: 'device-manager'` path into the device config blob.
1304
1280
  */
1305
- async function resolveDriverConfigSchema(deps, deviceId, hubLocal) {
1306
- if (hubLocal) {
1307
- const schema = hubLocal.device.getSettingsUISchema();
1308
- return schema.sections.length === 0 ? { status: "none" } : {
1309
- status: "ok",
1310
- schema: toWireShape(schema)
1311
- };
1312
- }
1313
- const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1314
- if (!ops) return { status: "none" };
1315
- const MAX_RETRIES = 2;
1316
- const RETRY_DELAY_MS = 750;
1317
- let lastErr;
1318
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1319
- if (attempt > 0) await require_dist.sleep(RETRY_DELAY_MS);
1320
- try {
1321
- const schema = await ops.getSettingsSchema({ deviceId });
1322
- if (!schema) return { status: "none" };
1323
- const wire = schema;
1324
- return wire.sections.length === 0 ? { status: "none" } : {
1325
- status: "ok",
1326
- schema: toWireShape(wire)
1327
- };
1328
- } catch (err) {
1329
- lastErr = err;
1330
- if (!isTransientMoleculerError(err)) break;
1331
- if (attempt < MAX_RETRIES) deps.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
1332
- tags: { deviceId },
1333
- meta: {
1334
- attempt: attempt + 1,
1335
- maxRetries: MAX_RETRIES,
1336
- error: require_dist.errMsg(err)
1337
- }
1338
- });
1339
- }
1340
- }
1341
- const msg = require_dist.errMsg(lastErr);
1342
- deps.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
1343
- tags: { deviceId },
1344
- meta: { error: msg }
1345
- });
1346
- return { status: "unavailable" };
1281
+ async function buildLinkedDevicesContribution(pctx, deviceId) {
1282
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1283
+ if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
1284
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1285
+ const config = parseLinkedDevicesConfig(blob);
1286
+ const options = all.filter((d) => d.id !== deviceId).map((d) => ({
1287
+ value: String(d.id),
1288
+ label: d.location !== null ? `${d.name} (${d.location})` : d.name
1289
+ }));
1290
+ return { sections: [{
1291
+ id: "linked-devices",
1292
+ title: "Linked devices",
1293
+ description: "Companion devices associated with this camera. Device-tree children are always linked; auto mode also links every device sharing the camera location, manual mode links the picked list instead.",
1294
+ order: 70,
1295
+ fields: [{
1296
+ type: "select",
1297
+ key: LINKED_MODE_CONFIG_KEY,
1298
+ label: "Link mode",
1299
+ description: "Auto: children + same-location devices. Manual: children + the list below.",
1300
+ options: [{
1301
+ value: "auto",
1302
+ label: "Auto (location-based)"
1303
+ }, {
1304
+ value: "manual",
1305
+ label: "Manual"
1306
+ }],
1307
+ value: config.mode
1308
+ }, {
1309
+ type: "multiselect",
1310
+ key: LINKED_IDS_CONFIG_KEY,
1311
+ label: "Manually linked devices",
1312
+ description: "Used in manual mode (children stay linked regardless).",
1313
+ showWhen: {
1314
+ field: LINKED_MODE_CONFIG_KEY,
1315
+ equals: "manual"
1316
+ },
1317
+ options,
1318
+ value: config.manualIds.map(String)
1319
+ }]
1320
+ }] };
1347
1321
  }
1348
1322
  /**
1349
- * Route a `derived-form` device-config cap's flat form patch to whichever
1350
- * mutation the bound provider actually exposes inspecting the
1351
- * provider's method surface, not the cap name. `setSettings` fires once
1352
- * with the parsed flat patch; `setProfile` fans the patch out per-profile
1353
- * via the registered builderId reducer.
1323
+ * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
1324
+ * The admin-ui save path reaches the device config blob via the
1325
+ * `writerCapName: 'device-manager'` routing (never this method), but the cap
1326
+ * contract includes the method keep it correct for direct callers.
1354
1327
  */
1355
- async function dispatchDerivedFormSave(builderId, deviceId, patch, dcProvider, capName) {
1356
- if (typeof dcProvider.setSettings === "function") {
1357
- const settings = parseDerivedFormSettingsPatch(builderId, patch);
1358
- await dcProvider.setSettings({
1359
- deviceId,
1360
- settings
1361
- });
1362
- return;
1363
- }
1364
- if (typeof dcProvider.setProfile === "function") {
1365
- const setProfile = dcProvider.setProfile;
1366
- await applyDerivedFormProfilePatch(builderId, patch, (profile, profilePatch) => setProfile({
1367
- deviceId,
1368
- profile,
1369
- patch: profilePatch
1370
- }));
1371
- return;
1372
- }
1373
- throw new Error(`[device-manager] device-config cap "${capName}" provider exposes neither setSettings nor setProfile`);
1328
+ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
1329
+ const next = { ...await pctx.settings.readDeviceStore(deviceId) };
1330
+ if ("_linkedDevicesMode" in patch) next[LINKED_MODE_CONFIG_KEY] = patch["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1331
+ if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
1332
+ await pctx.settings.writeDeviceStore(deviceId, next);
1333
+ return { success: true };
1374
1334
  }
1375
- async function updateDeviceField(deps, input) {
1376
- if (input.writerCapName === "device-manager") {
1377
- const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1378
- const found = hubRegistry ? resolveDeviceById(hubRegistry, input.deviceId) : null;
1379
- if (found) {
1380
- await found.device.applySettingsPatch({ [input.key]: input.value });
1381
- return { success: true };
1335
+ //#endregion
1336
+ //#region src/builtins/device-manager/device-store-sections.ts
1337
+ /** The device-manager's device-store-backed sections. Add an entry to extend. */
1338
+ var DEVICE_STORE_SECTIONS = [{
1339
+ keys: [LINKED_MODE_CONFIG_KEY, LINKED_IDS_CONFIG_KEY],
1340
+ apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
1341
+ }];
1342
+ /**
1343
+ * Split a `writerCapName: 'device-manager'` patch into device-store slices
1344
+ * (one per matching registered section) and the leftover driver patch.
1345
+ * Generic — consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
1346
+ */
1347
+ function splitDeviceStoreKeys(patch) {
1348
+ const claimed = /* @__PURE__ */ new Set();
1349
+ const storeGroups = [];
1350
+ for (const section of DEVICE_STORE_SECTIONS) {
1351
+ const slice = {};
1352
+ for (const key of section.keys) if (key in patch) {
1353
+ slice[key] = patch[key];
1354
+ claimed.add(key);
1382
1355
  }
1383
- const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", input.deviceId);
1384
- if (!ops) throw new Error(`[device-manager] device "${input.deviceId}" not found (no hub-local entry, no device-ops native provider)`);
1385
- await ops.setConfig({
1386
- deviceId: input.deviceId,
1387
- values: { [input.key]: input.value }
1356
+ if (Object.keys(slice).length > 0) storeGroups.push({
1357
+ section,
1358
+ patch: slice
1388
1359
  });
1389
- return { success: true };
1390
- }
1391
- const registry = deps.capabilityRegistry;
1392
- if (!registry) throw new Error("[device-manager] updateDeviceField requires capability registry — unavailable on this node");
1393
- const def = registry.getDefinition(input.writerCapName);
1394
- if (require_dist.isDeviceConfigCap(def)) {
1395
- if (def.deviceConfig.ui.kind === "widget") return { success: true };
1396
- const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
1397
- if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
1398
- await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, input.deviceId, { [input.key]: input.value }, dcProvider, input.writerCapName);
1399
- return { success: true };
1400
1360
  }
1401
- if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
1402
- await resolveContributionProvider(registry, def, input.writerCapName, input.writerAddonId, input.deviceId).applyDeviceSettingsPatch({
1403
- deviceId: input.deviceId,
1404
- patch: { [input.key]: input.value }
1405
- });
1406
- return { success: true };
1361
+ const driverPatch = {};
1362
+ for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
1363
+ return {
1364
+ storeGroups,
1365
+ driverPatch
1366
+ };
1407
1367
  }
1368
+ //#endregion
1369
+ //#region src/builtins/device-manager/device-bindings-store.ts
1408
1370
  /**
1409
- * Dispatch a device custom action. Hub-local devices run it directly;
1410
- * forked/remote devices route through the `device-ops` native cap
1411
- * (`runAction`) the same hub-local-then-device-ops fan-out that
1412
- * `updateDeviceField` uses for `applySettingsPatch`.
1413
- */
1414
- async function dispatchDeviceAction(deps, deviceId, action, input) {
1415
- const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1416
- const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1417
- if (found) return found.device.runDeviceAction(action, input);
1418
- const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1419
- if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found for runDeviceAction (no hub-local entry, no device-ops native provider)`);
1420
- return ops.runAction({
1421
- deviceId,
1422
- action,
1423
- input
1371
+ * Persisted wrapper-activation + per-device binding resolution for the
1372
+ * device-manager addon. Extracted from `device-manager.addon.ts` as free
1373
+ * functions that receive an explicit `BindingsDeps` context (dependency
1374
+ * injection) instead of closing over the addon instance — keeps the routing
1375
+ * logic cohesive and independently testable while preserving the EXACT call
1376
+ * semantics, ordering, and side effects of the original class methods.
1377
+ *
1378
+ * The addon keeps thin delegator methods (`getBindings`, `getAllBindings`, …)
1379
+ * that build `BindingsDeps` from its own state and forward here, so every
1380
+ * existing internal + cap-router call site is unchanged.
1381
+ */
1382
+ /**
1383
+ * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
1384
+ * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
1385
+ * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
1386
+ * full cluster view. Events from the local node are ignored: hub-local natives
1387
+ * live in `capabilityRegistry` and are folded in directly by getBindings.
1388
+ *
1389
+ * Push events are accurate in the steady state but can be lost during the
1390
+ * Moleculer transport handshake window (hub restart, crash-respawn,
1391
+ * restartAddon). The reliable replacement for lost events is the D3 re-handshake
1392
+ * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
1393
+ * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
1394
+ * handler purges a gone node's entries; the worker re-handshakes (and re-emits
1395
+ * `native-registered`) on its next boot.
1396
+ */
1397
+ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
1398
+ const localNodeId = ctx.kernel.localNodeId ?? "hub";
1399
+ ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (event) => {
1400
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
1401
+ if (nodeId === localNodeId) return;
1402
+ if (reason === "native-registered") {
1403
+ let perDevice = remoteNativeCaps.get(deviceId);
1404
+ if (!perDevice) {
1405
+ perDevice = /* @__PURE__ */ new Map();
1406
+ remoteNativeCaps.set(deviceId, perDevice);
1407
+ }
1408
+ perDevice.set(capName, {
1409
+ addonId,
1410
+ nodeId
1411
+ });
1412
+ } else if (reason === "native-unregistered") {
1413
+ const perDevice = remoteNativeCaps.get(deviceId);
1414
+ if (!perDevice) return;
1415
+ perDevice.delete(capName);
1416
+ if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
1417
+ }
1418
+ });
1419
+ const cluster = ctx.kernel.cluster;
1420
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
1421
+ const gone = payload.node.id;
1422
+ const emptyDevices = [];
1423
+ for (const [deviceId, perDevice] of remoteNativeCaps) {
1424
+ const toDelete = [];
1425
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
1426
+ for (const capName of toDelete) perDevice.delete(capName);
1427
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
1428
+ }
1429
+ for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
1424
1430
  });
1425
1431
  }
1432
+ async function readBindingsStore(deps) {
1433
+ return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
1434
+ }
1435
+ async function writeBindingsStore(deps, next) {
1436
+ await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
1437
+ }
1438
+ function resolveWrapperNodeId(_wrapperAddonId) {
1439
+ return "hub";
1440
+ }
1426
1441
  /**
1427
- * Resolve the `DeviceSettingsContribution` provider that owns a tagged
1428
- * field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
1429
- * from the binding that produced it — for `kind:'wrapped'` entries, that is
1430
- * the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
1431
- * the system addon id.
1442
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
1432
1443
  *
1433
- * Resolution order (Bug-3 fix):
1434
- * 1. `getProviderByAddon(capName, writerAddonId)`resolves the
1435
- * system-registered provider by the addon id from the tagged field.
1436
- * For wrapper bindings (snapshot, motion-detection, etc.) this directly
1437
- * returns the wrapper provider, bypassing the native-first resolution
1438
- * order of `getProviderForDevice` that caused "method not found".
1439
- * 2. `getSingleton(capName)` fallback for stale/mismatched writerAddonIds.
1440
- * The active singleton handles the contribution even if the addonId
1441
- * stored in the field is out of date (e.g. after an addon rename).
1444
+ * Every addon runs in its own `addon-runner` with the composite node id
1445
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
1446
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
1447
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
1448
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
1449
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
1450
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
1451
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
1452
+ * to an unknown node `no-provider`, which surfaces as
1453
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
1454
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
1455
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
1442
1456
  *
1443
- * `getProviderForDevice` is intentionally NOT used here: it returns the
1444
- * per-device native first when present (e.g. Reolink/ONVIF), and the native
1445
- * does NOT implement contribution methods — the root cause of Bug-3.
1457
+ * A flat node id (a genuine standalone node with no `/`) is returned
1458
+ * unchanged.
1446
1459
  */
1447
- function resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
1448
- const byAddon = registry.getProviderByAddon(writerCapName, writerAddonId);
1449
- if (byAddon) return byAddon;
1450
- const singleton = registry.getSingleton(writerCapName);
1451
- if (singleton) return singleton;
1452
- throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
1460
+ function toRoutableProviderNodeId(nodeId) {
1461
+ const slash = nodeId.indexOf("/");
1462
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
1453
1463
  }
1454
1464
  /**
1455
- * Batched counterpart of `updateDeviceField`. Groups changes by
1456
- * `(writerCapName, writerAddonId)` so each contributor receives a
1457
- * single `applyDeviceSettingsPatch` with all of its updates merged —
1458
- * avoids N round-trips for simultaneous edits in the same save.
1465
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
1466
+ * consulting the handshake-fed `HubNodeRegistry` via
1467
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
1468
+ * `remoteNativeCaps` cache misses covers the Moleculer transport
1469
+ * handshake window where `DeviceBindingsChanged` events were lost but the
1470
+ * D3 re-handshake (post device restore) has already populated the registry.
1459
1471
  *
1460
- * Per-provider failures are captured in the `failures[]` output so the
1461
- * admin UI can highlight which sections didn't persist; a failure on
1462
- * one provider does NOT abort the others.
1472
+ * Returns `null` when the entry is genuinely not present in the cluster
1473
+ * view (cap not registered on any worker for that device).
1463
1474
  */
1464
- async function updateDeviceFieldsBatch(deps, input) {
1465
- const groups = /* @__PURE__ */ new Map();
1466
- for (const change of input.changes) {
1467
- const key = `${change.writerCapName}::${change.writerAddonId}`;
1468
- const existing = groups.get(key);
1469
- if (existing) existing.patch[change.key] = change.value;
1470
- else groups.set(key, {
1471
- writerCapName: change.writerCapName,
1472
- writerAddonId: change.writerAddonId,
1473
- patch: { [change.key]: change.value }
1475
+ function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
1476
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1477
+ if (!clusterCaps) return null;
1478
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
1479
+ addonId: entry.addonId,
1480
+ nodeId: entry.nodeId
1481
+ };
1482
+ return null;
1483
+ }
1484
+ async function getBindings(deps, input) {
1485
+ const storeKey = String(input.deviceId);
1486
+ const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
1487
+ const entries = [];
1488
+ const seenCaps = /* @__PURE__ */ new Set();
1489
+ const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
1490
+ for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
1491
+ const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
1492
+ const remoteNative = resolveRemote(capName);
1493
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1494
+ const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
1495
+ if (wrapperAddonId === null && !nativeAddonId) {
1496
+ seenCaps.add(capName);
1497
+ continue;
1498
+ }
1499
+ entries.push({
1500
+ capName,
1501
+ kind: wrapperAddonId ? "wrapped" : "native",
1502
+ providerAddonId: wrapperAddonId ?? nativeAddonId,
1503
+ providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
1504
+ nativeAddonId
1474
1505
  });
1506
+ seenCaps.add(capName);
1475
1507
  }
1476
- const failures = [];
1477
- for (const group of groups.values()) try {
1478
- await applyGroupPatch(deps, input.deviceId, group);
1479
- } catch (err) {
1480
- failures.push({
1481
- writerCapName: group.writerCapName,
1482
- writerAddonId: group.writerAddonId,
1483
- error: err instanceof Error ? err.message : String(err)
1508
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
1509
+ if (seenCaps.has(capName)) continue;
1510
+ const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
1511
+ if (!defaultWrapperAddonId) continue;
1512
+ const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
1513
+ const remoteNative = resolveRemote(capName);
1514
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1515
+ entries.push({
1516
+ capName,
1517
+ kind: "wrapped",
1518
+ providerAddonId: defaultWrapperAddonId,
1519
+ providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
1520
+ nativeAddonId
1484
1521
  });
1522
+ seenCaps.add(capName);
1485
1523
  }
1486
- return {
1487
- success: true,
1488
- failures
1489
- };
1490
- }
1491
- /** Apply a single grouped patch to the appropriate provider. Mirrors
1492
- * `updateDeviceField` routing (special-case device-manager, else
1493
- * registry lookup). Used by `updateDeviceFieldsBatch`. */
1494
- async function applyGroupPatch(deps, deviceId, group) {
1495
- if (group.writerCapName === "device-manager") {
1496
- const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1497
- const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1498
- if (found) {
1499
- await found.device.applySettingsPatch(group.patch);
1500
- return;
1501
- }
1502
- const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1503
- if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found (no hub-local entry, no device-ops native provider)`);
1504
- await ops.setConfig({
1505
- deviceId,
1506
- values: group.patch
1524
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
1525
+ if (seenCaps.has(capName)) continue;
1526
+ const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
1527
+ entries.push({
1528
+ capName,
1529
+ kind: "native",
1530
+ providerAddonId: nativeAddonId,
1531
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1532
+ nativeAddonId
1507
1533
  });
1508
- return;
1534
+ seenCaps.add(capName);
1509
1535
  }
1510
- const registry = deps.capabilityRegistry;
1511
- if (!registry) throw new Error("[device-manager] capability registry unavailable");
1512
- const def = registry.getDefinition(group.writerCapName);
1513
- if (require_dist.isDeviceConfigCap(def)) {
1514
- if (def.deviceConfig.ui.kind === "widget") return;
1515
- const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
1516
- if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
1517
- await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, deviceId, group.patch, dcProvider, group.writerCapName);
1518
- return;
1536
+ const pushFed = deps.remoteNativeCaps.get(input.deviceId);
1537
+ if (pushFed) for (const [capName, info] of pushFed) {
1538
+ if (seenCaps.has(capName)) continue;
1539
+ entries.push({
1540
+ capName,
1541
+ kind: "native",
1542
+ providerAddonId: info.addonId,
1543
+ providerNodeId: toRoutableProviderNodeId(info.nodeId),
1544
+ nativeAddonId: info.addonId
1545
+ });
1546
+ seenCaps.add(capName);
1519
1547
  }
1520
- if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
1521
- await resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
1522
- deviceId,
1523
- patch: group.patch
1524
- });
1525
- }
1526
- async function listWrappersForCap(deps, input) {
1527
- return [...deps.capabilityRegistry?.getWrappersForCap(input.capName) ?? []];
1528
- }
1529
- async function listBindableCapsForDeviceType(deps, input) {
1530
- const registry = deps.capabilityRegistry;
1531
- if (!registry) return [];
1532
- return registry.listDeviceScopedCapsForType(input.deviceType).map((capName) => ({
1533
- capName,
1534
- wrappers: [...registry.getWrappersForCap(capName)]
1535
- }));
1536
- }
1537
- async function setWrapperActive(deps, input) {
1538
- const storeKey = String(input.deviceId);
1539
- const store = await readBindingsStore(deps.bindingsDeps);
1540
- const perDevice = { ...store.deviceBindings[storeKey] };
1541
- if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
1542
- else perDevice[input.capName] = { wrapperAddonId: null };
1543
- const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
1544
- ...store.deviceBindings,
1545
- [storeKey]: perDevice
1546
- } : (() => {
1547
- const { [storeKey]: _drop, ...rest } = store.deviceBindings;
1548
- return rest;
1549
- })();
1550
- await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
1551
- deps.ctx.eventBus.emit({
1552
- id: (0, node_crypto.randomUUID)(),
1553
- timestamp: /* @__PURE__ */ new Date(),
1554
- source: {
1555
- type: "addon",
1556
- id: deps.ctx.id
1557
- },
1558
- category: require_dist.EventCategory.DeviceBindingsChanged,
1559
- data: {
1560
- deviceId: input.deviceId,
1561
- capName: input.capName,
1562
- reason: input.active ? "wrapper-activated" : "wrapper-deactivated",
1563
- addonId: input.wrapperAddonId,
1564
- nodeId: resolveWrapperNodeId(input.wrapperAddonId)
1548
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1549
+ if (clusterCaps) for (const entry of clusterCaps) {
1550
+ if (entry.deviceId !== input.deviceId) continue;
1551
+ if (seenCaps.has(entry.capName)) continue;
1552
+ if (!entry.addonId) continue;
1553
+ const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
1554
+ if (entry.nodeId === localNodeId) continue;
1555
+ entries.push({
1556
+ capName: entry.capName,
1557
+ kind: "native",
1558
+ providerAddonId: entry.addonId,
1559
+ providerNodeId: toRoutableProviderNodeId(entry.nodeId),
1560
+ nativeAddonId: entry.addonId
1561
+ });
1562
+ seenCaps.add(entry.capName);
1563
+ }
1564
+ if (deps.devicesWithLinks?.has(input.deviceId)) {
1565
+ const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
1566
+ for (const link of row?.deviceLinks ?? []) {
1567
+ const capName = link.target.cap;
1568
+ if (seenCaps.has(capName)) continue;
1569
+ entries.push({
1570
+ capName,
1571
+ kind: "linked",
1572
+ providerAddonId: deps.ctx.id,
1573
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1574
+ nativeAddonId: ""
1575
+ });
1576
+ seenCaps.add(capName);
1565
1577
  }
1566
- });
1567
- }
1568
- /**
1569
- * Build the wireable-cap entry for one capability definition: the status
1570
- * schema's scalar leaf fields plus — for an item-array cap
1571
- * (`status.itemArray`) — the per-item fields tagged `item: true` (a link
1572
- * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
1573
- * descriptor so the UI can address items. Returns null when the cap exposes
1574
- * nothing wireable (no status schema / no leaf fields).
1575
- */
1576
- function wireableEntryForDef(capName, def) {
1577
- const status = def.status;
1578
- const schema = status?.schema;
1579
- if (!schema) return null;
1580
- const itemArray = status.itemArray;
1581
- const fields = [...require_dist.enumerateSchemaFields(schema), ...itemArray ? require_dist.enumerateItemArrayFields(itemArray) : []].map((f) => ({
1582
- path: f.path,
1583
- kind: f.kind,
1584
- ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
1585
- ...f.item === true ? { item: true } : {}
1586
- }));
1587
- if (fields.length === 0) return null;
1578
+ }
1588
1579
  return {
1589
- cap: capName,
1590
- fields,
1591
- ...itemArray ? { itemArray: {
1592
- path: itemArray.path,
1593
- keyField: itemArray.keyField
1594
- } } : {}
1580
+ deviceId: input.deviceId,
1581
+ entries
1595
1582
  };
1596
1583
  }
1597
1584
  /**
1598
- * Per-device wireable-field catalog the domain status fields a device's
1599
- * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
1600
- * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
1601
- * internal book-keeping, not wireable domain data), and enumerates each cap's
1602
- * status schema leaf fields (per-item fields included for item-array caps).
1585
+ * Whole-fleet binding dump. Iterates every device known to the
1586
+ * deviceRegistry and reuses the per-device `getBindings` resolver
1587
+ * for each same routing rules, single round-trip. Used by
1588
+ * `SystemManager.init()` for warm-boot.
1603
1589
  *
1604
- * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
1605
- * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
1606
- * marker) and whose `deviceTypes` admit this device's persisted type — so the
1607
- * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
1608
- * authored before any binding exists. Default (absent/false) behavior is
1609
- * byte-identical to the binding-driven catalog.
1590
+ * Bindings change rarely (wrapper toggle, device add/remove) so
1591
+ * clients invalidate via the existing
1592
+ * `capability.binding-changed` event rather than re-fetching this
1593
+ * payload periodically.
1610
1594
  */
1611
- async function getWireableFields(deps, input) {
1612
- const { deviceId } = input;
1613
- const reg = deps.capabilityRegistry;
1614
- if (!reg) return { caps: [] };
1615
- const { entries } = await getBindings(deps.bindingsDeps, { deviceId });
1616
- const seen = /* @__PURE__ */ new Set();
1617
- const caps = [];
1618
- for (const entry of entries) {
1619
- if (seen.has(entry.capName)) continue;
1620
- seen.add(entry.capName);
1621
- const def = reg.getDefinition(entry.capName);
1622
- if (!def || def.kind === "wrapper") continue;
1623
- const wireable = wireableEntryForDef(entry.capName, def);
1624
- if (wireable) caps.push(wireable);
1625
- }
1626
- if (input.includeSynthesizable === true) {
1627
- const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
1628
- if (deviceType !== void 0) for (const def of require_dist.ALL_CAPABILITY_DEFINITIONS) {
1629
- if (seen.has(def.name)) continue;
1630
- if (def.scope !== "device" || def.kind === "wrapper") continue;
1631
- if (def.status?.empty === void 0) continue;
1632
- if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
1633
- seen.add(def.name);
1634
- const wireable = wireableEntryForDef(def.name, def);
1635
- if (wireable) caps.push(wireable);
1636
- }
1637
- }
1638
- return { caps };
1595
+ async function getAllBindings(deps) {
1596
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1597
+ if (!hubRegistry) return [];
1598
+ const out = [];
1599
+ for (const device of hubRegistry.getAll()) out.push(await getBindings(deps, { deviceId: device.id }));
1600
+ return out;
1601
+ }
1602
+ /**
1603
+ * Resolve a numeric deviceId to a stableId via persisted meta.
1604
+ * Used only by the device-identity section of the device-details
1605
+ * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
1606
+ * a readonly display field. All runtime/registry lookups are keyed by
1607
+ * numeric deviceId; this helper is display-only.
1608
+ */
1609
+ async function lookupPersistedStableId(deps, deviceId) {
1610
+ return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
1639
1611
  }
1640
1612
  //#endregion
1641
- //#region src/builtins/device-manager/device-event-propagator.ts
1613
+ //#region src/builtins/device-manager/device-aggregation.ts
1642
1614
  /**
1643
- * Walks the parent chain for every device-sourced event and re-emits a
1644
- * copy on each ancestor scope with `via[]` populated.
1645
- *
1646
- * Design goals:
1647
- * - Transparent: drivers emit once on their own device scope; the
1648
- * framework handles fan-out. Zero provider boilerplate.
1649
- * - Anti-loop: events that already carry `via[]` are skipped (we only
1650
- * propagate ORIGINAL emissions).
1651
- * - Anti-cycle: the parent chain is bounded — if the device registry
1652
- * is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
1653
- * and logs a warning.
1654
- * - Lazy: parent chain is resolved on-demand per event (no cached
1655
- * topology). The lookup is O(depth) which is ≤2 in practice.
1656
- *
1657
- * `via` contract (from SystemEvent.via JSDoc):
1658
- * - `via[0]` is the originating source (the device that produced the
1659
- * event). Subsequent entries walk up the parent chain.
1660
- * - On the re-emission, `source` is the ancestor at that level and
1661
- * `via[0..i]` is the prefix of the chain up to and including the
1662
- * first N ancestors below the current one.
1615
+ * Device-details aggregation + field-update routing for the device-manager
1616
+ * addon. Extracted from `device-manager.addon.ts` as free functions that
1617
+ * receive an explicit `AggregationDeps` context (DI) rather than closing over
1618
+ * the addon instance.
1663
1619
  *
1664
- * Example (grandchild parent grandparent):
1665
- * Original: { source: {id: 7}, data: {...}, via: undefined }
1666
- * Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
1667
- * Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
1620
+ * This module owns the binding-driven settings/live/status aggregators
1621
+ * (`getDeviceAggregate`, `collectSystemDeviceContributions`,
1622
+ * `deriveDeviceConfigContribution`, `buildBaseDeviceSection`,
1623
+ * `getDeviceStatusAggregate`, `resolveDriverConfigSchema`) and the
1624
+ * field-update routing (`updateDeviceField`, `updateDeviceFieldsBatch`,
1625
+ * `applyGroupPatch`, `resolveContributionProvider`, `dispatchDeviceAction`)
1626
+ * plus the wrapper-activation mutators (`setWrapperActive`,
1627
+ * `listWrappersForCap`, `listBindableCapsForDeviceType`).
1668
1628
  *
1669
- * A consumer listening at `source.id === 1` receives re-emit 2 (with
1670
- * `via` showing the chain). A consumer listening at `source.id === 7`
1671
- * with `via === undefined` receives the original only.
1629
+ * Sibling aggregator functions call each other directly (passing `deps`);
1630
+ * cross-domain leaf calls (`getBindings`, `lookupPersistedStableId`,
1631
+ * `resolveRemoteNativeCapFromRegistry`, read/writeBindingsStore) route through
1632
+ * the bindings-store module via `deps.bindingsDeps`. EXACT semantics, ordering,
1633
+ * and side effects of the original class methods are preserved; the addon keeps
1634
+ * thin delegator methods over these functions.
1672
1635
  */
1673
- /** Bounded walk paranoia against corrupt device registries with cycles. */
1674
- var MAX_CHAIN_DEPTH = 16;
1675
- var DeviceEventPropagator = class {
1676
- opts;
1677
- unsubscribe = null;
1678
- constructor(opts) {
1679
- this.opts = opts;
1680
- }
1681
- start() {
1682
- if (this.unsubscribe) return;
1683
- const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
1684
- this.unsubscribe = unsub;
1685
- }
1686
- stop() {
1687
- if (!this.unsubscribe) return;
1688
- this.unsubscribe();
1689
- this.unsubscribe = null;
1690
- }
1691
- /** Exposed for tests — lets them inject events without the full bus. */
1692
- handle(ev) {
1693
- if (ev.via !== void 0) return;
1694
- if (ev.source.type !== "device") return;
1695
- const rawId = ev.source.id;
1696
- const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
1697
- if (!Number.isFinite(deviceId)) return;
1698
- const chain = this.resolveParentChain(deviceId);
1699
- if (chain.length === 0) return;
1700
- const via = [ev.source];
1701
- for (const ancestorId of chain) {
1702
- const reEmission = {
1703
- ...ev,
1704
- source: {
1705
- type: "device",
1706
- id: ancestorId
1707
- },
1708
- via: [...via]
1709
- };
1710
- this.opts.eventBus.emit(reEmission);
1711
- via.push({
1712
- type: "device",
1713
- id: ancestorId
1714
- });
1715
- }
1636
+ async function getDeviceAggregate(deps, deviceId, kind) {
1637
+ const registry = deps.capabilityRegistry;
1638
+ if (!registry) {
1639
+ deps.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
1640
+ return null;
1716
1641
  }
1717
- resolveParentChain(deviceId) {
1718
- const chain = [];
1719
- const seen = new Set([deviceId]);
1720
- let current = this.opts.getParentOf(deviceId);
1721
- while (current != null) {
1722
- if (seen.has(current)) {
1723
- this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
1724
- tags: { deviceId },
1725
- meta: {
1726
- cycleAt: current,
1727
- chainSoFar: [...chain]
1728
- }
1729
- });
1730
- return chain;
1731
- }
1732
- seen.add(current);
1733
- chain.push(current);
1734
- if (chain.length >= MAX_CHAIN_DEPTH) {
1735
- this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
1736
- tags: { deviceId },
1737
- meta: {
1738
- depth: chain.length,
1739
- max: MAX_CHAIN_DEPTH
1740
- }
1741
- });
1742
- break;
1743
- }
1744
- current = this.opts.getParentOf(current);
1642
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
1643
+ const { entries: bindingEntries } = await getBindings(deps.bindingsDeps, { deviceId });
1644
+ const seenCaps = new Set(bindingEntries.map((e) => e.capName));
1645
+ const results = await Promise.all(bindingEntries.map(async (entry) => {
1646
+ const def = registry.getDefinition(entry.capName);
1647
+ if (require_dist.isDeviceConfigCap(def)) return deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind);
1648
+ if (!def?.exposesDeviceSettings) return null;
1649
+ const provider = registry.getProviderByAddon(entry.capName, entry.providerAddonId);
1650
+ if (!provider) return null;
1651
+ try {
1652
+ const contribution = await provider[method]({ deviceId });
1653
+ if (!contribution) return null;
1654
+ return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
1655
+ } catch (err) {
1656
+ const msg = err instanceof Error ? err.message : String(err);
1657
+ deps.ctx.logger.warn("contribution method failed", {
1658
+ tags: {
1659
+ deviceId,
1660
+ addonId: entry.providerAddonId
1661
+ },
1662
+ meta: {
1663
+ capName: entry.capName,
1664
+ method,
1665
+ error: msg
1666
+ }
1667
+ });
1668
+ return null;
1745
1669
  }
1746
- return chain;
1747
- }
1748
- };
1749
- //#endregion
1750
- //#region src/builtins/device-manager/device-queries.ts
1670
+ }));
1671
+ const base = kind === "settings" ? await buildBaseDeviceSection(deps, deviceId) : null;
1672
+ const systemResults = await collectSystemDeviceContributions(deps, deviceId, kind, seenCaps);
1673
+ const parts = [
1674
+ ...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [],
1675
+ ...results.filter((r) => r !== null),
1676
+ ...systemResults
1677
+ ];
1678
+ if (parts.length === 0) return null;
1679
+ return mergeAggregates(parts);
1680
+ }
1751
1681
  /**
1752
- * Device query + live-runtime + provider/adoption operations for the
1753
- * device-manager addon — the read/live half of the `IDeviceManagerProvider`
1754
- * surface. Extracted verbatim from the `onInitialize` provider literal as free
1755
- * functions taking a `ProviderContext` (DI). Input/return types are pinned to
1756
- * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
1757
- * `ReturnType<…>` so the typed surface is identical to the inferred methods.
1682
+ * System-scoped per-device contributions the deliberate exception to
1683
+ * binding-driven aggregation (D12).
1758
1684
  *
1759
- * Covers: persisted/live device queries (listPersistedByAddon, listAll,
1760
- * getDevice, getChildren), live runtime methods (getStreamSources,
1761
- * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
1762
- * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
1763
- * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
1764
- * createDevice, testCreationField), and device-adoption ops
1765
- * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
1685
+ * A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
1686
+ * state but is NOT bound per-device: it is a system addon, not a BaseDevice,
1687
+ * so it never calls `registerNativeCap` and never appears in
1688
+ * `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
1689
+ * per-device panel never renders. Two such caps ship today:
1690
+ * - `stream-broker` (singleton) the RTSP-restream / pre-buffer settings
1691
+ * tab, scoped to the cameras whose streams it serves.
1692
+ * - `device-export` (collection) — the "Export" panel; a device can be
1693
+ * exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
1766
1694
  *
1767
- * The hub-live-then-persisted-fallback union, the cross-process device-ops
1768
- * dispatch, the depth-first remove cascade, and the resetToSource resync purge
1769
- * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
1770
- * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
1771
- * `pctx.provider`. Pure refactorno behavior change.
1695
+ * D12 bans fanning a per-device call out to every same-named provider of a
1696
+ * `scope: 'device'` cap, because vendor providers SHARE cap names
1697
+ * (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
1698
+ * vendor-shared a system cap name maps to one active provider (singleton)
1699
+ * or one declared collection so consulting the cap's own provider(s)
1700
+ * directly is safe. Each provider gates internally by deviceId and returns
1701
+ * null for devices it does not own.
1702
+ *
1703
+ * `seenCaps` skips any system cap that already contributed via a binding so
1704
+ * its section is never duplicated.
1772
1705
  */
1773
- async function listPersistedByAddon(pctx, input) {
1774
- const { addonId } = input;
1775
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
1776
- const stableIds = index[addonId] ?? [];
1777
- const byStableId = /* @__PURE__ */ new Map();
1778
- for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
1779
- return stableIds.map((stableId) => {
1780
- const m = byStableId.get(stableId);
1781
- return {
1782
- id: m.id,
1783
- stableId,
1784
- type: m.type,
1785
- name: m.name,
1786
- location: m.location ?? null,
1787
- disabled: m.disabled ?? false,
1788
- parentDeviceId: m.parentDeviceId
1789
- };
1790
- });
1791
- }
1792
- async function listAll(pctx, input) {
1793
- const { addonId } = input;
1794
- const results = [];
1795
- const seen = /* @__PURE__ */ new Set();
1796
- const meta = await pctx.metaStore.readMeta();
1797
- const metadataMap = await pctx.metaStore.readMetadataMap();
1798
- if (pctx.registry) {
1799
- const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
1800
- addonId,
1801
- device
1802
- })) : pctx.registry.getAllWithAddonId();
1803
- for (const { addonId: aid, device } of liveEntries) {
1804
- const key = String(device.id);
1805
- const metadata = metadataMap[key] ?? null;
1806
- const metaRow = meta[key] ?? null;
1807
- results.push(toDeviceInfo(aid, device, metadata, metaRow));
1808
- seen.add(key);
1809
- }
1810
- }
1811
- const index = await pctx.metaStore.readIndex();
1812
- const metaByAddonStable = /* @__PURE__ */ new Map();
1813
- for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
1814
- const targetAddons = addonId ? [addonId] : Object.keys(index);
1815
- for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
1816
- const m = metaByAddonStable.get(`${aid}${stableId}`);
1817
- const key = String(m.id);
1818
- if (seen.has(key)) continue;
1819
- const persistedType = m.type;
1820
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1821
- const metadata = metadataMap[key] ?? null;
1822
- results.push({
1823
- id: m.id,
1824
- stableId,
1825
- addonId: aid,
1826
- type: persistedType,
1827
- name: m?.name ?? stableId,
1828
- location: m?.location ?? null,
1829
- disabled: m?.disabled ?? false,
1830
- parentDeviceId: m?.parentDeviceId ?? null,
1831
- role: toDeviceRole(m?.role),
1832
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1833
- probed: pctx.host.resolveDeviceProbed(m.id),
1834
- features: persistedFeatures(m?.features),
1835
- isCamera: persistedType === require_dist.DeviceType.Camera,
1836
- config: persistedConfig ?? {},
1837
- metadata,
1838
- ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1839
- ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1840
- ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1841
- ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1842
- ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1843
- ...m?.display !== void 0 ? { display: m.display } : {},
1844
- ...(() => {
1845
- const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1846
- return si !== void 0 ? { sourceInfo: si } : {};
1847
- })()
1848
- });
1849
- }
1850
- return results;
1851
- }
1852
- async function getDevice(pctx, input) {
1853
- const { deviceId } = input;
1854
- if (pctx.registry) {
1855
- const found = resolveDeviceById(pctx.registry, deviceId);
1856
- if (found) {
1857
- const key = String(found.device.id);
1858
- const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
1859
- const metadata = map[key] ?? null;
1860
- const metaRow = metaMap[key] ?? null;
1861
- return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
1862
- }
1863
- }
1864
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1865
- if (!persisted) return null;
1866
- const { addonId: aid, stableId, meta: m } = persisted;
1867
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1868
- const key = String(deviceId);
1869
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
1870
- const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1871
- return {
1872
- id: deviceId,
1873
- stableId,
1874
- addonId: aid,
1875
- type: m.type,
1876
- name: m.name,
1877
- location: m.location ?? null,
1878
- disabled: m.disabled ?? false,
1879
- parentDeviceId: m.parentDeviceId,
1880
- role: toDeviceRole(m.role),
1881
- online: pctx.host.resolveDeviceOnline(deviceId, true),
1882
- probed: pctx.host.resolveDeviceProbed(deviceId),
1883
- features: persistedFeatures(m.features),
1884
- isCamera: false,
1885
- config: persistedConfig ?? {},
1886
- metadata,
1887
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1888
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1889
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1890
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1891
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1892
- ...m.display !== void 0 ? { display: m.display } : {},
1893
- ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
1894
- };
1895
- }
1896
- async function getChildren(pctx, input) {
1897
- const { parentDeviceId } = input;
1898
- let ownerAddonId = null;
1899
- if (pctx.registry) {
1900
- if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
1901
- }
1902
- if (!ownerAddonId) {
1903
- const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
1904
- if (!persisted) return [];
1905
- ownerAddonId = persisted.addonId;
1906
- }
1907
- const results = [];
1908
- const seen = /* @__PURE__ */ new Set();
1909
- const [index, meta, metadataMap] = await Promise.all([
1910
- pctx.metaStore.readIndex(),
1911
- pctx.metaStore.readMeta(),
1912
- pctx.metaStore.readMetadataMap()
1913
- ]);
1914
- if (pctx.registry) {
1915
- const liveChildren = pctx.registry.getChildren(parentDeviceId);
1916
- for (const device of liveChildren) {
1917
- const key = String(device.id);
1918
- const metadata = metadataMap[key] ?? null;
1919
- const metaRow = meta[key] ?? null;
1920
- results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
1921
- seen.add(key);
1922
- }
1923
- }
1924
- const ownerMetaByStableId = /* @__PURE__ */ new Map();
1925
- for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
1926
- const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
1927
- for (const childStableId of persistedChildren) {
1928
- const m = ownerMetaByStableId.get(childStableId);
1929
- const key = String(m.id);
1930
- if (seen.has(key)) continue;
1931
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1932
- const metadata = metadataMap[key] ?? null;
1933
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1934
- results.push({
1935
- id: m.id,
1936
- stableId: childStableId,
1937
- addonId: ownerAddonId,
1938
- type: m.type,
1939
- name: m.name,
1940
- location: m.location ?? null,
1941
- disabled: m.disabled ?? false,
1942
- parentDeviceId,
1943
- role: toDeviceRole(m.role),
1944
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1945
- probed: pctx.host.resolveDeviceProbed(m.id),
1946
- features: persistedFeatures(m.features),
1947
- isCamera: false,
1948
- config: persistedConfig ?? {},
1949
- metadata,
1950
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1951
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1952
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1953
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1954
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
1955
- ...m.display !== void 0 ? { display: m.display } : {},
1956
- ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
1957
- });
1706
+ async function collectSystemDeviceContributions(deps, deviceId, kind, seenCaps) {
1707
+ const registry = deps.capabilityRegistry;
1708
+ if (!registry) return [];
1709
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
1710
+ const tasks = [];
1711
+ for (const info of registry.listCapabilities()) {
1712
+ const def = registry.getDefinition(info.name);
1713
+ if (!def || def.scope !== "system" || !def.exposesDeviceSettings) continue;
1714
+ if (seenCaps.has(info.name)) continue;
1715
+ const addonIds = def.mode === "collection" ? info.providers : info.activeProvider ? [info.activeProvider] : [];
1716
+ for (const addonId of addonIds) tasks.push((async () => {
1717
+ const provider = registry.getProviderByAddon(info.name, addonId);
1718
+ if (!provider) return null;
1719
+ try {
1720
+ const contribution = await provider[method]({ deviceId });
1721
+ if (!contribution) return null;
1722
+ return tagContribution(toWireShape(contribution), info.name, addonId, kind);
1723
+ } catch (err) {
1724
+ deps.ctx.logger.warn("system device-settings contribution skipped", {
1725
+ tags: {
1726
+ deviceId,
1727
+ addonId
1728
+ },
1729
+ meta: {
1730
+ capName: info.name,
1731
+ method,
1732
+ error: err instanceof Error ? err.message : String(err)
1733
+ }
1734
+ });
1735
+ return null;
1736
+ }
1737
+ })());
1958
1738
  }
1959
- return results;
1739
+ return (await Promise.all(tasks)).filter((r) => r !== null);
1960
1740
  }
1961
- async function getStreamSources(pctx, input) {
1962
- const { deviceId } = input;
1963
- if (pctx.registry) {
1964
- const found = resolveDeviceById(pctx.registry, deviceId);
1965
- if (found) {
1966
- if (!isCameraDevice(found.device)) return [];
1967
- return (await found.device.getStreamSources()).map((s) => ({
1741
+ /**
1742
+ * D14: framework-derived device-config contribution.
1743
+ *
1744
+ * `kind === 'live'` — device-config caps contribute nothing to the live
1745
+ * aggregate (they hold editable config, not live observables).
1746
+ *
1747
+ * `ui.kind === 'widget'` — emits a single structural `type:'widget'`
1748
+ * section; the widget self-persists via the cap's own mutations.
1749
+ *
1750
+ * `ui.kind === 'derived-form'` — calls `getOptions`/`getStatus` on the
1751
+ * bound provider, runs the registered pure builder, and returns the
1752
+ * derived form sections. Returns null when the camera exposes nothing
1753
+ * configurable or the provider is not yet registered.
1754
+ */
1755
+ async function deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind) {
1756
+ if (kind === "live") return null;
1757
+ const ui = def.deviceConfig.ui;
1758
+ if (ui.kind === "widget") return tagContribution({ sections: [{
1759
+ id: `${entry.capName}-widget`,
1760
+ title: ui.label,
1761
+ tab: ui.tab,
1762
+ order: ui.order ?? 0,
1763
+ ...ui.topTab ? { location: "top-tab" } : {},
1764
+ fields: [{
1765
+ type: "widget",
1766
+ key: `${entry.capName}Widget`,
1767
+ label: ui.label,
1768
+ widgetId: ui.widgetId
1769
+ }]
1770
+ }] }, entry.capName, entry.providerAddonId, kind);
1771
+ const provider = registry.getProviderForDevice(entry.capName, deviceId);
1772
+ if (!provider || typeof provider.getOptions !== "function") return null;
1773
+ try {
1774
+ const options = await provider.getOptions({ deviceId });
1775
+ const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
1776
+ const derived = deriveFormContribution(ui.builderId, options, status ?? null);
1777
+ if (!derived) return null;
1778
+ return tagContribution({
1779
+ ...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
1780
+ sections: derived.sections.map((s) => ({
1968
1781
  id: s.id,
1969
- label: s.label,
1970
- protocol: s.protocol,
1971
- url: s.url,
1972
- resolution: s.resolution,
1973
- fps: s.fps,
1974
- bitrate: s.bitrate,
1975
- codec: s.codec,
1976
- profileHint: s.profileHint
1977
- }));
1978
- }
1979
- }
1980
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1981
- return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
1982
- }
1983
- async function getConfigSchema(pctx, input) {
1984
- const { deviceId } = input;
1985
- if (pctx.registry) {
1986
- const found = resolveDeviceById(pctx.registry, deviceId);
1987
- if (found) return found.device.config.entries().map((entry) => ({
1988
- key: entry.key,
1989
- value: entry.value,
1990
- ...entry.description !== void 0 ? { description: entry.description } : {}
1991
- }));
1992
- }
1993
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1994
- return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
1995
- }
1996
- async function getSettingsSchema(pctx, input) {
1997
- const { deviceId } = input;
1998
- if (pctx.registry) {
1999
- const found = resolveDeviceById(pctx.registry, deviceId);
2000
- if (found) return found.device.getSettingsUISchema();
1782
+ title: s.title,
1783
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
1784
+ ...s.order !== void 0 ? { order: s.order } : {},
1785
+ ...s.description !== void 0 ? { description: s.description } : {},
1786
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
1787
+ fields: [...s.fields]
1788
+ }))
1789
+ }, entry.capName, entry.providerAddonId, kind);
1790
+ } catch (err) {
1791
+ const msg = err instanceof Error ? err.message : String(err);
1792
+ deps.ctx.logger.warn("device-config derivation failed", {
1793
+ tags: {
1794
+ deviceId,
1795
+ addonId: entry.providerAddonId
1796
+ },
1797
+ meta: {
1798
+ capName: entry.capName,
1799
+ error: msg
1800
+ }
1801
+ });
1802
+ return null;
2001
1803
  }
2002
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
2003
- return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2004
1804
  }
2005
- async function updateConfig(pctx, input) {
2006
- const { deviceId } = input;
2007
- if (pctx.registry) {
2008
- const found = resolveDeviceById(pctx.registry, deviceId);
2009
- if (found) {
2010
- await found.device.config.setAll(input.values);
2011
- return { success: true };
2012
- }
2013
- }
2014
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2015
- await pctx.requireDeviceOps(deviceId).setConfig({
2016
- deviceId,
2017
- values: input.values
1805
+ /**
1806
+ * Build the device-manager's own contribution to the aggregator — the
1807
+ * device identity (id, stableId, addonId, type, online) + the
1808
+ * driver-specific config exposed by the device class via
1809
+ * `zodEntriesToConfigUI`.
1810
+ *
1811
+ * Two paths, deliberately symmetric with `getSettingsSchema`:
1812
+ *
1813
+ * - Hub-local: device's IDevice instance lives in this process'
1814
+ * DeviceRegistry, we read config + schema directly by reference.
1815
+ * - Cross-process: device lives in a forked worker (RtspCamera on
1816
+ * provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
1817
+ * `device-ops.getSettingsSchema` native provider for a wire-
1818
+ * serializable ConfigUISchema and merge it in under the same
1819
+ * "Driver Config" section, so the UI sees the same shape regardless
1820
+ * of where the IDevice physically runs.
1821
+ *
1822
+ * Returns `null` only when the device genuinely doesn't exist anywhere
1823
+ * (no hub-local, no persisted ownership, no device-ops native). The
1824
+ * aggregator falls back to contributor sections only in that case.
1825
+ */
1826
+ async function buildBaseDeviceSection(deps, deviceId) {
1827
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1828
+ const hubLocal = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1829
+ const stableId = hubLocal?.device.stableId ?? await lookupPersistedStableId(deps.bindingsDeps, deviceId);
1830
+ const nativeOwner = resolveNativeDeviceOwner(deps, deviceId);
1831
+ const addonId = hubLocal?.addonId ?? nativeOwner?.addonId ?? null;
1832
+ if (!hubLocal && !nativeOwner) return null;
1833
+ const sections = [{
1834
+ id: "device-identity",
1835
+ title: "Identity",
1836
+ tab: "general",
1837
+ order: 0,
1838
+ fields: [
1839
+ {
1840
+ type: "text",
1841
+ key: "_deviceId",
1842
+ label: "Device ID",
1843
+ readonlyField: true,
1844
+ value: String(deviceId)
1845
+ },
1846
+ {
1847
+ type: "text",
1848
+ key: "_stableId",
1849
+ label: "Stable ID",
1850
+ readonlyField: true,
1851
+ value: stableId ?? ""
1852
+ },
1853
+ {
1854
+ type: "text",
1855
+ key: "_addonId",
1856
+ label: "Driver",
1857
+ readonlyField: true,
1858
+ value: addonId ?? "unknown"
1859
+ },
1860
+ ...hubLocal ? [{
1861
+ type: "text",
1862
+ key: "_type",
1863
+ label: "Type",
1864
+ readonlyField: true,
1865
+ value: hubLocal.device.type
1866
+ }, {
1867
+ type: "text",
1868
+ key: "_online",
1869
+ label: "Online",
1870
+ readonlyField: true,
1871
+ value: hubLocal.device.online ? "yes" : "no"
1872
+ }] : []
1873
+ ]
1874
+ }];
1875
+ const driverResult = await resolveDriverConfigSchema(deps, deviceId, hubLocal);
1876
+ if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
1877
+ id: section.id,
1878
+ title: section.title,
1879
+ tab: section.tab ?? "general",
1880
+ order: section.order ?? 1,
1881
+ fields: [...section.fields],
1882
+ ...section.description !== void 0 ? { description: section.description } : {},
1883
+ ...section.columns !== void 0 ? { columns: section.columns } : {}
2018
1884
  });
2019
- return { success: true };
2020
- }
2021
- async function enable(pctx, input) {
2022
- await pctx.provider.setDisabled({
2023
- deviceId: input.deviceId,
2024
- disabled: false
1885
+ else if (driverResult.status === "unavailable") sections.push({
1886
+ id: "driver-settings-unavailable",
1887
+ title: "Driver Settings",
1888
+ tab: "general",
1889
+ order: 1,
1890
+ fields: [{
1891
+ type: "info",
1892
+ key: "driver-settings-unavailable-notice",
1893
+ label: "Temporarily unavailable",
1894
+ content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
1895
+ variant: "warning"
1896
+ }]
2025
1897
  });
2026
- return { success: true };
1898
+ return {
1899
+ sections,
1900
+ ...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
1901
+ };
2027
1902
  }
2028
- async function disable(pctx, input) {
2029
- await pctx.provider.setDisabled({
2030
- deviceId: input.deviceId,
2031
- disabled: true
2032
- });
2033
- return { success: true };
1903
+ /**
1904
+ * Lookup the native owner for `device-ops` on `deviceId` — the native-cap
1905
+ * registry (hub-local and remote) is keyed by numeric id.
1906
+ */
1907
+ function resolveNativeDeviceOwner(deps, deviceId) {
1908
+ const local = deps.capabilityRegistry?.getNativeAddonId("device-ops", deviceId) ?? null;
1909
+ if (local) return {
1910
+ addonId: local,
1911
+ nodeId: deps.ctx.kernel.localNodeId ?? "hub"
1912
+ };
1913
+ const remote = deps.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? resolveRemoteNativeCapFromRegistry(deps.bindingsDeps, "device-ops", deviceId);
1914
+ return remote ? {
1915
+ addonId: remote.addonId,
1916
+ nodeId: remote.nodeId
1917
+ } : null;
2034
1918
  }
2035
- async function remove(pctx, input) {
2036
- const { deviceId } = input;
2037
- const removeOne = async (id) => {
2038
- if (pctx.registry) {
2039
- const live = resolveDeviceById(pctx.registry, id);
2040
- if (live) {
2041
- const deviceName = live.device.name;
2042
- await live.device.removeDevice();
2043
- pctx.registry.remove(id);
2044
- await pctx.provider.removeDevice({ deviceId: id });
2045
- pctx.host.ctx.logger.info("removed hub-local device", { tags: {
2046
- deviceId: id,
2047
- deviceName
2048
- } });
1919
+ /**
1920
+ * Aggregate `status` across every registered cap for a device.
1921
+ *
1922
+ * Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
1923
+ * omitted), looks up a native provider per cap via the capability
1924
+ * registry, calls `provider.getStatus({ deviceId })`, and validates
1925
+ * the return against the cap's own `status.schema`. Validation
1926
+ * failures log a warning and yield `null` for that cap so the
1927
+ * overall aggregate stays usable — a single misbehaving provider
1928
+ * must not blank out a device's entire status view.
1929
+ *
1930
+ * Returned shape is `Record<capName, unknown | null>`; the client-
1931
+ * side hook tightens this to `CapStatusTypeMap` via the generated
1932
+ * `cap-status-types.ts`.
1933
+ */
1934
+ async function getDeviceStatusAggregate(deps, input) {
1935
+ const capNames = input.caps ?? require_dist.CAP_NAMES_WITH_STATUS;
1936
+ const registry = deps.capabilityRegistry;
1937
+ const out = {};
1938
+ if (!registry) {
1939
+ for (const name of capNames) out[name] = null;
1940
+ return out;
1941
+ }
1942
+ await Promise.all(capNames.map(async (capName) => {
1943
+ try {
1944
+ const def = registry.getDefinition(capName);
1945
+ if (!def?.status) {
1946
+ out[capName] = null;
2049
1947
  return;
2050
1948
  }
2051
- }
2052
- const persisted = await pctx.metaStore.resolvePersistedById(id);
2053
- if (!persisted) return;
2054
- const { meta: persistedMeta } = persisted;
2055
- try {
2056
- await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
1949
+ const provider = registry.getProviderForDevice(capName, input.deviceId);
1950
+ if (!provider || typeof provider.getStatus !== "function") {
1951
+ out[capName] = null;
1952
+ return;
1953
+ }
1954
+ const raw = await provider.getStatus({ deviceId: input.deviceId });
1955
+ if (raw == null) {
1956
+ out[capName] = null;
1957
+ return;
1958
+ }
1959
+ const parsed = def.status.schema.safeParse(raw);
1960
+ if (!parsed.success) {
1961
+ deps.ctx.logger.warn("getDeviceStatusAggregate: provider returned invalid status, dropping", {
1962
+ tags: { deviceId: input.deviceId },
1963
+ meta: {
1964
+ capName,
1965
+ issues: parsed.error.issues.slice(0, 3)
1966
+ }
1967
+ });
1968
+ out[capName] = null;
1969
+ return;
1970
+ }
1971
+ out[capName] = parsed.data;
2057
1972
  } catch (err) {
2058
- pctx.host.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
2059
- tags: {
2060
- deviceId: id,
2061
- deviceName: persistedMeta.name
2062
- },
2063
- meta: { error: require_dist.errMsg(err) }
1973
+ deps.ctx.logger.warn("getDeviceStatusAggregate: provider threw, dropping", {
1974
+ tags: { deviceId: input.deviceId },
1975
+ meta: {
1976
+ capName,
1977
+ error: require_dist.errMsg(err)
1978
+ }
2064
1979
  });
1980
+ out[capName] = null;
2065
1981
  }
2066
- await pctx.provider.removeDevice({ deviceId: id });
2067
- };
2068
- const removeCascade = async (id) => {
2069
- for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
2070
- await removeOne(id);
2071
- };
2072
- await removeCascade(deviceId);
2073
- return { success: true };
1982
+ }));
1983
+ return out;
2074
1984
  }
2075
1985
  /**
2076
- * Cascade-delete every top-level device whose `integrationId`
2077
- * matches. Enumerates a SNAPSHOT of the meta map so concurrent
2078
- * removals don't clobber each other. Only top-level parents are
2079
- * enumerated children cascade via the per-parent `removeCascade`
2080
- * inside the delegated `remove` call. Idempotent: devices with no
2081
- * `integrationId` never match.
1986
+ * Return the driver-specific device-settings contribution. Hub-local
1987
+ * devices call `getSettingsUISchema()` directly; forked-worker devices
1988
+ * go through the `device-ops.getSettingsSchema` cap method on the
1989
+ * numeric-id-keyed native registry.
1990
+ *
1991
+ * Returns a discriminated result so callers can distinguish three states:
1992
+ * 'ok' – schema obtained successfully
1993
+ * 'none' – driver genuinely has no settings schema
1994
+ * 'unavailable' – worker was unreachable after retries (transient)
2082
1995
  */
2083
- async function removeByIntegration(pctx, input) {
2084
- const { integrationId } = input;
2085
- const meta = await pctx.metaStore.readMeta();
2086
- const parentKeys = Object.keys(meta).filter((key) => {
2087
- const m = meta[key];
2088
- return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
2089
- });
2090
- let removed = 0;
2091
- for (const _key of parentKeys) {
2092
- const m = meta[_key];
2093
- if (!m) continue;
2094
- await pctx.provider.remove({ deviceId: m.id });
2095
- removed++;
2096
- }
2097
- return { removed };
2098
- }
2099
- async function getStreamProfileMap(pctx, input) {
2100
- if (!pctx.registry) return {};
2101
- const found = resolveDeviceById(pctx.registry, input.deviceId);
2102
- if (!found) return {};
2103
- const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
2104
- if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
2105
- if (!isCameraDevice(found.device)) return {};
2106
- const sources = await found.device.getStreamSources();
2107
- const profileMap = {};
2108
- for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
2109
- return profileMap;
2110
- }
2111
- async function setStreamProfileMap(pctx, input) {
2112
- const { deviceId } = input;
2113
- if (pctx.registry) {
2114
- const found = resolveDeviceById(pctx.registry, deviceId);
2115
- if (found) {
2116
- await found.device.config.setAll({ _profileMap: input.profileMap });
2117
- return { success: true };
2118
- }
1996
+ async function resolveDriverConfigSchema(deps, deviceId, hubLocal) {
1997
+ if (hubLocal) {
1998
+ const schema = hubLocal.device.getSettingsUISchema();
1999
+ return schema.sections.length === 0 ? { status: "none" } : {
2000
+ status: "ok",
2001
+ schema: toWireShape(schema)
2002
+ };
2119
2003
  }
2120
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2121
- await pctx.requireDeviceOps(deviceId).setConfig({
2122
- deviceId,
2123
- values: { _profileMap: input.profileMap }
2124
- });
2125
- return { success: true };
2126
- }
2127
- async function probeStreams(pctx, input) {
2128
- const streamProbe = pctx.host.ctx.kernel.streamProbe;
2129
- if (!streamProbe) return [];
2130
- const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
2131
- const results = [];
2132
- for (const s of sources) {
2133
- if (!s.url) continue;
2004
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
2005
+ if (!ops) return { status: "none" };
2006
+ const MAX_RETRIES = 2;
2007
+ const RETRY_DELAY_MS = 750;
2008
+ let lastErr;
2009
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
2010
+ if (attempt > 0) await require_dist.sleep(RETRY_DELAY_MS);
2134
2011
  try {
2135
- const metadata = await streamProbe.probe(s.url, { force: true });
2136
- results.push({
2137
- streamId: s.id,
2138
- width: metadata.width,
2139
- height: metadata.height,
2140
- codec: metadata.codec,
2141
- fps: metadata.fps,
2142
- bitrateKbps: metadata.bitrateKbps
2143
- });
2012
+ const schema = await ops.getSettingsSchema({ deviceId });
2013
+ if (!schema) return { status: "none" };
2014
+ const wire = schema;
2015
+ return wire.sections.length === 0 ? { status: "none" } : {
2016
+ status: "ok",
2017
+ schema: toWireShape(wire)
2018
+ };
2144
2019
  } catch (err) {
2145
- pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
2146
- deviceId: input.deviceId,
2147
- streamId: s.id,
2148
- error: err instanceof Error ? err.message : String(err)
2149
- } });
2150
- results.push({ streamId: s.id });
2020
+ lastErr = err;
2021
+ if (!isTransientMoleculerError(err)) break;
2022
+ if (attempt < MAX_RETRIES) deps.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
2023
+ tags: { deviceId },
2024
+ meta: {
2025
+ attempt: attempt + 1,
2026
+ maxRetries: MAX_RETRIES,
2027
+ error: require_dist.errMsg(err)
2028
+ }
2029
+ });
2151
2030
  }
2152
2031
  }
2153
- return results;
2154
- }
2155
- async function discoverDevices(pctx, input) {
2156
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
2157
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
2158
- return (await dp.discoverDevices({})).map((d) => ({
2159
- stableId: d.stableId,
2160
- type: d.type,
2161
- suggestedName: d.suggestedName,
2162
- prefilledConfig: d.prefilledConfig
2163
- }));
2164
- }
2165
- async function adoptDevice(pctx, input) {
2166
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
2167
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
2168
- const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
2169
- if (input.integrationId !== void 0) try {
2170
- await pctx.stampIntegrationId(summary.id, input.integrationId);
2171
- } catch (err) {
2172
- pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
2173
- tags: {
2174
- deviceId: summary.id,
2175
- integrationId: input.integrationId
2176
- },
2177
- meta: { error: require_dist.errMsg(err) }
2178
- });
2179
- }
2180
- return summary;
2181
- }
2182
- async function getCreationSchema(pctx, input) {
2183
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
2184
- if (!await dp.supportsManualCreation({})) return null;
2185
- return await dp.getChildCreationSchema({ type: input.type }) ?? null;
2186
- }
2187
- async function createDevice(pctx, input) {
2188
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
2189
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
2190
- const summary = await dp.createDevice({
2191
- type: input.type,
2192
- config: input.config
2032
+ const msg = require_dist.errMsg(lastErr);
2033
+ deps.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
2034
+ tags: { deviceId },
2035
+ meta: { error: msg }
2193
2036
  });
2194
- if (input.integrationId !== void 0) try {
2195
- await pctx.stampIntegrationId(summary.id, input.integrationId);
2196
- } catch (err) {
2197
- pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
2198
- tags: {
2199
- deviceId: summary.id,
2200
- integrationId: input.integrationId
2201
- },
2202
- meta: { error: require_dist.errMsg(err) }
2037
+ return { status: "unavailable" };
2038
+ }
2039
+ /**
2040
+ * Route a `derived-form` device-config cap's flat form patch to whichever
2041
+ * mutation the bound provider actually exposes — inspecting the
2042
+ * provider's method surface, not the cap name. `setSettings` fires once
2043
+ * with the parsed flat patch; `setProfile` fans the patch out per-profile
2044
+ * via the registered builderId reducer.
2045
+ */
2046
+ async function dispatchDerivedFormSave(builderId, deviceId, patch, dcProvider, capName) {
2047
+ if (typeof dcProvider.setSettings === "function") {
2048
+ const settings = parseDerivedFormSettingsPatch(builderId, patch);
2049
+ await dcProvider.setSettings({
2050
+ deviceId,
2051
+ settings
2203
2052
  });
2053
+ return;
2204
2054
  }
2205
- return summary;
2055
+ if (typeof dcProvider.setProfile === "function") {
2056
+ const setProfile = dcProvider.setProfile;
2057
+ await applyDerivedFormProfilePatch(builderId, patch, (profile, profilePatch) => setProfile({
2058
+ deviceId,
2059
+ profile,
2060
+ patch: profilePatch
2061
+ }));
2062
+ return;
2063
+ }
2064
+ throw new Error(`[device-manager] device-config cap "${capName}" provider exposes neither setSettings nor setProfile`);
2206
2065
  }
2207
- async function testCreationField(pctx, input) {
2208
- return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
2209
- type: input.type,
2210
- key: input.key,
2211
- value: input.value,
2212
- ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
2066
+ async function updateDeviceField(deps, input) {
2067
+ await applyGroupPatch(deps, input.deviceId, {
2068
+ writerCapName: input.writerCapName,
2069
+ writerAddonId: input.writerAddonId,
2070
+ patch: { [input.key]: input.value }
2213
2071
  });
2072
+ return { success: true };
2214
2073
  }
2215
- async function adoptionListCandidates(pctx, input) {
2216
- const { addonId, ...rest } = input;
2217
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
2218
- }
2219
- async function adoptionRefresh(pctx, input) {
2220
- const { addonId, integrationId } = input;
2221
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
2222
- }
2223
- async function adoptionAdopt(pctx, input) {
2224
- const { addonId, ...rest } = input;
2225
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
2074
+ /**
2075
+ * Dispatch a device custom action. Hub-local devices run it directly;
2076
+ * forked/remote devices route through the `device-ops` native cap
2077
+ * (`runAction`) — the same hub-local-then-device-ops fan-out that
2078
+ * `updateDeviceField` uses for `applySettingsPatch`.
2079
+ */
2080
+ async function dispatchDeviceAction(deps, deviceId, action, input) {
2081
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
2082
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
2083
+ if (found) return found.device.runDeviceAction(action, input);
2084
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
2085
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found for runDeviceAction (no hub-local entry, no device-ops native provider)`);
2086
+ return ops.runAction({
2087
+ deviceId,
2088
+ action,
2089
+ input
2090
+ });
2226
2091
  }
2227
- async function adoptionRelease(pctx, input) {
2228
- const { addonId, ...rest } = input;
2229
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
2092
+ /**
2093
+ * Resolve the `DeviceSettingsContribution` provider that owns a tagged
2094
+ * field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
2095
+ * from the binding that produced it — for `kind:'wrapped'` entries, that is
2096
+ * the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
2097
+ * the system addon id.
2098
+ *
2099
+ * Resolution order (Bug-3 fix):
2100
+ * 1. `getProviderByAddon(capName, writerAddonId)` — resolves the
2101
+ * system-registered provider by the addon id from the tagged field.
2102
+ * For wrapper bindings (snapshot, motion-detection, etc.) this directly
2103
+ * returns the wrapper provider, bypassing the native-first resolution
2104
+ * order of `getProviderForDevice` that caused "method not found".
2105
+ * 2. `getSingleton(capName)` — fallback for stale/mismatched writerAddonIds.
2106
+ * The active singleton handles the contribution even if the addonId
2107
+ * stored in the field is out of date (e.g. after an addon rename).
2108
+ *
2109
+ * `getProviderForDevice` is intentionally NOT used here: it returns the
2110
+ * per-device native first when present (e.g. Reolink/ONVIF), and the native
2111
+ * does NOT implement contribution methods — the root cause of Bug-3.
2112
+ */
2113
+ function resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
2114
+ const byAddon = registry.getProviderByAddon(writerCapName, writerAddonId);
2115
+ if (byAddon) return byAddon;
2116
+ const singleton = registry.getSingleton(writerCapName);
2117
+ if (singleton) return singleton;
2118
+ throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
2230
2119
  }
2231
- async function adoptionResync(pctx, input) {
2232
- const { camDeviceId, resetToSource } = input;
2233
- let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
2234
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
2235
- if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
2236
- let removedChildren = 0;
2237
- if (resetToSource === true) {
2238
- const childIds = await pctx.metaStore.directChildIds(camDeviceId);
2239
- for (const childId of childIds) {
2240
- await pctx.provider.remove({ deviceId: childId });
2241
- removedChildren += 1;
2242
- }
2243
- pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
2244
- deviceId: camDeviceId,
2245
- removedChildren
2246
- } });
2120
+ /**
2121
+ * Batched counterpart of `updateDeviceField`. Groups changes by
2122
+ * `(writerCapName, writerAddonId)` so each contributor receives a
2123
+ * single `applyDeviceSettingsPatch` with all of its updates merged —
2124
+ * avoids N round-trips for simultaneous edits in the same save.
2125
+ *
2126
+ * Per-provider failures are captured in the `failures[]` output so the
2127
+ * admin UI can highlight which sections didn't persist; a failure on
2128
+ * one provider does NOT abort the others.
2129
+ */
2130
+ async function updateDeviceFieldsBatch(deps, input) {
2131
+ const groups = /* @__PURE__ */ new Map();
2132
+ for (const change of input.changes) {
2133
+ const key = `${change.writerCapName}::${change.writerAddonId}`;
2134
+ const existing = groups.get(key);
2135
+ if (existing) existing.patch[change.key] = change.value;
2136
+ else groups.set(key, {
2137
+ writerCapName: change.writerCapName,
2138
+ writerAddonId: change.writerAddonId,
2139
+ patch: { [change.key]: change.value }
2140
+ });
2141
+ }
2142
+ const failures = [];
2143
+ for (const group of groups.values()) try {
2144
+ await applyGroupPatch(deps, input.deviceId, group);
2145
+ } catch (err) {
2146
+ failures.push({
2147
+ writerCapName: group.writerCapName,
2148
+ writerAddonId: group.writerAddonId,
2149
+ error: err instanceof Error ? err.message : String(err)
2150
+ });
2247
2151
  }
2248
2152
  return {
2249
- ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
2250
- camDeviceId,
2251
- resetToSource
2252
- }),
2253
- removedChildren
2153
+ success: true,
2154
+ failures
2254
2155
  };
2255
2156
  }
2256
- async function testField(pctx, input) {
2257
- const { deviceId } = input;
2258
- let owningAddonId = null;
2259
- if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
2260
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
2261
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
2262
- const dp = await pctx.host.waitDeviceProvider(owningAddonId);
2263
- if (!dp) return {
2264
- status: "ok",
2265
- labels: [],
2266
- error: void 0
2267
- };
2268
- if (typeof dp.testCreationField !== "function") return {
2269
- status: "ok",
2270
- labels: [],
2271
- error: void 0
2272
- };
2273
- return dp.testCreationField({
2274
- type: require_dist.DeviceType.Camera,
2275
- key: input.key,
2276
- value: input.value
2157
+ /** Apply a single grouped patch to the appropriate provider. Mirrors
2158
+ * `updateDeviceField` routing (special-case device-manager, else
2159
+ * registry lookup). Used by `updateDeviceFieldsBatch`. */
2160
+ async function applyGroupPatch(deps, deviceId, group) {
2161
+ if (group.writerCapName === "device-manager") {
2162
+ const { storeGroups, driverPatch } = splitDeviceStoreKeys(group.patch);
2163
+ if (storeGroups.length > 0) {
2164
+ const settings = deps.ctx.settings;
2165
+ if (!settings) throw new Error("[device-manager] device-store save requires the settings surface");
2166
+ for (const { section, patch } of storeGroups) await section.apply({ settings }, deviceId, patch);
2167
+ }
2168
+ if (Object.keys(driverPatch).length === 0) return;
2169
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
2170
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
2171
+ if (found) {
2172
+ await found.device.applySettingsPatch(driverPatch);
2173
+ return;
2174
+ }
2175
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
2176
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found (no hub-local entry, no device-ops native provider)`);
2177
+ await ops.setConfig({
2178
+ deviceId,
2179
+ values: driverPatch
2180
+ });
2181
+ return;
2182
+ }
2183
+ const registry = deps.capabilityRegistry;
2184
+ if (!registry) throw new Error("[device-manager] capability registry unavailable");
2185
+ const def = registry.getDefinition(group.writerCapName);
2186
+ if (require_dist.isDeviceConfigCap(def)) {
2187
+ if (def.deviceConfig.ui.kind === "widget") return;
2188
+ const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
2189
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
2190
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, deviceId, group.patch, dcProvider, group.writerCapName);
2191
+ return;
2192
+ }
2193
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
2194
+ await resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
2195
+ deviceId,
2196
+ patch: group.patch
2277
2197
  });
2278
2198
  }
2279
- //#endregion
2280
- //#region src/builtins/device-manager/device-linked-devices.ts
2281
- /** Reserved device-config keys (cf. `_profileMap`). */
2282
- var LINKED_MODE_CONFIG_KEY = "_linkedDevicesMode";
2283
- var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
2284
- /** Parse the two reserved keys out of a device-config blob. Tolerant:
2285
- * ids may arrive as numbers or numeric strings (the multiselect field
2286
- * stores string option values); anything else is dropped. */
2287
- function parseLinkedDevicesConfig(blob) {
2288
- const mode = blob["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
2289
- const rawIds = blob[LINKED_IDS_CONFIG_KEY];
2290
- const manualIds = [];
2291
- if (Array.isArray(rawIds)) for (const v of rawIds) {
2292
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
2293
- if (Number.isInteger(n) && !manualIds.includes(n)) manualIds.push(n);
2294
- }
2295
- return {
2296
- mode,
2297
- manualIds
2298
- };
2199
+ async function listWrappersForCap(deps, input) {
2200
+ return [...deps.capabilityRegistry?.getWrappersForCap(input.capName) ?? []];
2299
2201
  }
2300
- /**
2301
- * Pure resolution core: children ∪ (auto → same-location | manual → list),
2302
- * minus self, deduped. Order: children first, then mode additions.
2303
- */
2304
- function resolveLinkedDeviceIds(params) {
2305
- const { selfId, selfLocation, config, candidates } = params;
2306
- const ids = /* @__PURE__ */ new Set();
2307
- for (const c of candidates) if (c.parentDeviceId === selfId) ids.add(c.id);
2308
- if (config.mode === "auto") {
2309
- if (selfLocation !== null && selfLocation.length > 0) {
2310
- for (const c of candidates) if (c.location === selfLocation) ids.add(c.id);
2202
+ async function listBindableCapsForDeviceType(deps, input) {
2203
+ const registry = deps.capabilityRegistry;
2204
+ if (!registry) return [];
2205
+ return registry.listDeviceScopedCapsForType(input.deviceType).map((capName) => ({
2206
+ capName,
2207
+ wrappers: [...registry.getWrappersForCap(capName)]
2208
+ }));
2209
+ }
2210
+ async function setWrapperActive(deps, input) {
2211
+ const storeKey = String(input.deviceId);
2212
+ const store = await readBindingsStore(deps.bindingsDeps);
2213
+ const perDevice = { ...store.deviceBindings[storeKey] };
2214
+ if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
2215
+ else perDevice[input.capName] = { wrapperAddonId: null };
2216
+ const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
2217
+ ...store.deviceBindings,
2218
+ [storeKey]: perDevice
2219
+ } : (() => {
2220
+ const { [storeKey]: _drop, ...rest } = store.deviceBindings;
2221
+ return rest;
2222
+ })();
2223
+ await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
2224
+ deps.ctx.eventBus.emit({
2225
+ id: (0, node_crypto.randomUUID)(),
2226
+ timestamp: /* @__PURE__ */ new Date(),
2227
+ source: {
2228
+ type: "addon",
2229
+ id: deps.ctx.id
2230
+ },
2231
+ category: require_dist.EventCategory.DeviceBindingsChanged,
2232
+ data: {
2233
+ deviceId: input.deviceId,
2234
+ capName: input.capName,
2235
+ reason: input.active ? "wrapper-activated" : "wrapper-deactivated",
2236
+ addonId: input.wrapperAddonId,
2237
+ nodeId: resolveWrapperNodeId(input.wrapperAddonId)
2311
2238
  }
2312
- } else {
2313
- const known = new Set(candidates.map((c) => c.id));
2314
- for (const id of config.manualIds) if (known.has(id)) ids.add(id);
2315
- }
2316
- ids.delete(selfId);
2317
- return [...ids];
2318
- }
2319
- /** Cap implementation of `getLinkedDevices`. */
2320
- async function getLinkedDevices(pctx, input) {
2321
- const { deviceId } = input;
2322
- const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
2323
- const config = parseLinkedDevicesConfig(blob);
2324
- const byId = new Map(all.map((d) => [d.id, d]));
2325
- const ids = resolveLinkedDeviceIds({
2326
- selfId: deviceId,
2327
- selfLocation: byId.get(deviceId)?.location ?? null,
2328
- config,
2329
- candidates: all
2330
2239
  });
2331
- const devices = [];
2332
- for (const id of ids) {
2333
- const d = byId.get(id);
2334
- if (!d) continue;
2335
- devices.push({
2336
- deviceId: d.id,
2337
- name: d.name,
2338
- location: d.location,
2339
- features: d.features
2340
- });
2341
- }
2342
- return {
2343
- mode: config.mode,
2344
- devices
2345
- };
2346
2240
  }
2347
2241
  /**
2348
- * "Linked devices" settings section for a CAMERA (null for every other
2349
- * device typethe association is camera-anchored). Contributed through
2350
- * `collectSystemDeviceContributions`; saves route through the existing
2351
- * `writerCapName: 'device-manager'` path into the device config blob.
2242
+ * Build the wireable-cap entry for one capability definition: the status
2243
+ * schema's scalar leaf fields plus for an item-array cap
2244
+ * (`status.itemArray`) the per-item fields tagged `item: true` (a link
2245
+ * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
2246
+ * descriptor so the UI can address items. Returns null when the cap exposes
2247
+ * nothing wireable (no status schema / no leaf fields).
2352
2248
  */
2353
- async function buildLinkedDevicesContribution(pctx, deviceId) {
2354
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2355
- if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
2356
- const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
2357
- const config = parseLinkedDevicesConfig(blob);
2358
- const options = all.filter((d) => d.id !== deviceId).map((d) => ({
2359
- value: String(d.id),
2360
- label: d.location !== null ? `${d.name} (${d.location})` : d.name
2249
+ function wireableEntryForDef(capName, def) {
2250
+ const status = def.status;
2251
+ const schema = status?.schema;
2252
+ if (!schema) return null;
2253
+ const itemArray = status.itemArray;
2254
+ const fields = [...require_dist.enumerateSchemaFields(schema), ...itemArray ? require_dist.enumerateItemArrayFields(itemArray) : []].map((f) => ({
2255
+ path: f.path,
2256
+ kind: f.kind,
2257
+ ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
2258
+ ...f.item === true ? { item: true } : {}
2361
2259
  }));
2362
- return { sections: [{
2363
- id: "linked-devices",
2364
- title: "Linked devices",
2365
- description: "Companion devices associated with this camera. Device-tree children are always linked; auto mode also links every device sharing the camera location, manual mode links the picked list instead.",
2366
- order: 70,
2367
- fields: [{
2368
- type: "select",
2369
- key: LINKED_MODE_CONFIG_KEY,
2370
- label: "Link mode",
2371
- description: "Auto: children + same-location devices. Manual: children + the list below.",
2372
- options: [{
2373
- value: "auto",
2374
- label: "Auto (location-based)"
2375
- }, {
2376
- value: "manual",
2377
- label: "Manual"
2378
- }],
2379
- value: config.mode
2380
- }, {
2381
- type: "multiselect",
2382
- key: LINKED_IDS_CONFIG_KEY,
2383
- label: "Manually linked devices",
2384
- description: "Used in manual mode (children stay linked regardless).",
2385
- options,
2386
- value: config.manualIds.map(String)
2387
- }]
2388
- }] };
2260
+ if (fields.length === 0) return null;
2261
+ return {
2262
+ cap: capName,
2263
+ fields,
2264
+ ...itemArray ? { itemArray: {
2265
+ path: itemArray.path,
2266
+ keyField: itemArray.keyField
2267
+ } } : {}
2268
+ };
2389
2269
  }
2390
2270
  /**
2391
- * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
2392
- * The admin-ui save path reaches the device config blob via the
2393
- * `writerCapName: 'device-manager'` routing (never this method), but the cap
2394
- * contract includes the method keep it correct for direct callers.
2271
+ * Per-device wireable-field catalog the domain status fields a device's
2272
+ * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
2273
+ * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
2274
+ * internal book-keeping, not wireable domain data), and enumerates each cap's
2275
+ * status schema leaf fields (per-item fields included for item-array caps).
2276
+ *
2277
+ * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
2278
+ * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
2279
+ * marker) and whose `deviceTypes` admit this device's persisted type — so the
2280
+ * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
2281
+ * authored before any binding exists. Default (absent/false) behavior is
2282
+ * byte-identical to the binding-driven catalog.
2395
2283
  */
2396
- async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
2397
- const next = { ...await pctx.settings.readDeviceStore(deviceId) };
2398
- if ("_linkedDevicesMode" in patch) next[LINKED_MODE_CONFIG_KEY] = patch["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
2399
- if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
2400
- await pctx.settings.writeDeviceStore(deviceId, next);
2401
- return { success: true };
2284
+ async function getWireableFields(deps, input) {
2285
+ const { deviceId } = input;
2286
+ const reg = deps.capabilityRegistry;
2287
+ if (!reg) return { caps: [] };
2288
+ const { entries } = await getBindings(deps.bindingsDeps, { deviceId });
2289
+ const seen = /* @__PURE__ */ new Set();
2290
+ const caps = [];
2291
+ for (const entry of entries) {
2292
+ if (seen.has(entry.capName)) continue;
2293
+ seen.add(entry.capName);
2294
+ const def = reg.getDefinition(entry.capName);
2295
+ if (!def || def.kind === "wrapper") continue;
2296
+ const wireable = wireableEntryForDef(entry.capName, def);
2297
+ if (wireable) caps.push(wireable);
2298
+ }
2299
+ if (input.includeSynthesizable === true) {
2300
+ const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
2301
+ if (deviceType !== void 0) for (const def of require_dist.ALL_CAPABILITY_DEFINITIONS) {
2302
+ if (seen.has(def.name)) continue;
2303
+ if (def.scope !== "device" || def.kind === "wrapper") continue;
2304
+ if (def.status?.empty === void 0) continue;
2305
+ if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
2306
+ seen.add(def.name);
2307
+ const wireable = wireableEntryForDef(def.name, def);
2308
+ if (wireable) caps.push(wireable);
2309
+ }
2310
+ }
2311
+ return { caps };
2402
2312
  }
2403
2313
  //#endregion
2314
+ //#region src/builtins/device-manager/device-event-propagator.ts
2315
+ /**
2316
+ * Walks the parent chain for every device-sourced event and re-emits a
2317
+ * copy on each ancestor scope with `via[]` populated.
2318
+ *
2319
+ * Design goals:
2320
+ * - Transparent: drivers emit once on their own device scope; the
2321
+ * framework handles fan-out. Zero provider boilerplate.
2322
+ * - Anti-loop: events that already carry `via[]` are skipped (we only
2323
+ * propagate ORIGINAL emissions).
2324
+ * - Anti-cycle: the parent chain is bounded — if the device registry
2325
+ * is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
2326
+ * and logs a warning.
2327
+ * - Lazy: parent chain is resolved on-demand per event (no cached
2328
+ * topology). The lookup is O(depth) which is ≤2 in practice.
2329
+ *
2330
+ * `via` contract (from SystemEvent.via JSDoc):
2331
+ * - `via[0]` is the originating source (the device that produced the
2332
+ * event). Subsequent entries walk up the parent chain.
2333
+ * - On the re-emission, `source` is the ancestor at that level and
2334
+ * `via[0..i]` is the prefix of the chain up to and including the
2335
+ * first N ancestors below the current one.
2336
+ *
2337
+ * Example (grandchild → parent → grandparent):
2338
+ * Original: { source: {id: 7}, data: {...}, via: undefined }
2339
+ * Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
2340
+ * Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
2341
+ *
2342
+ * A consumer listening at `source.id === 1` receives re-emit 2 (with
2343
+ * `via` showing the chain). A consumer listening at `source.id === 7`
2344
+ * with `via === undefined` receives the original only.
2345
+ */
2346
+ /** Bounded walk — paranoia against corrupt device registries with cycles. */
2347
+ var MAX_CHAIN_DEPTH = 16;
2348
+ var DeviceEventPropagator = class {
2349
+ opts;
2350
+ unsubscribe = null;
2351
+ constructor(opts) {
2352
+ this.opts = opts;
2353
+ }
2354
+ start() {
2355
+ if (this.unsubscribe) return;
2356
+ const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
2357
+ this.unsubscribe = unsub;
2358
+ }
2359
+ stop() {
2360
+ if (!this.unsubscribe) return;
2361
+ this.unsubscribe();
2362
+ this.unsubscribe = null;
2363
+ }
2364
+ /** Exposed for tests — lets them inject events without the full bus. */
2365
+ handle(ev) {
2366
+ if (ev.via !== void 0) return;
2367
+ if (ev.source.type !== "device") return;
2368
+ const rawId = ev.source.id;
2369
+ const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
2370
+ if (!Number.isFinite(deviceId)) return;
2371
+ const chain = this.resolveParentChain(deviceId);
2372
+ if (chain.length === 0) return;
2373
+ const via = [ev.source];
2374
+ for (const ancestorId of chain) {
2375
+ const reEmission = {
2376
+ ...ev,
2377
+ source: {
2378
+ type: "device",
2379
+ id: ancestorId
2380
+ },
2381
+ via: [...via]
2382
+ };
2383
+ this.opts.eventBus.emit(reEmission);
2384
+ via.push({
2385
+ type: "device",
2386
+ id: ancestorId
2387
+ });
2388
+ }
2389
+ }
2390
+ resolveParentChain(deviceId) {
2391
+ const chain = [];
2392
+ const seen = new Set([deviceId]);
2393
+ let current = this.opts.getParentOf(deviceId);
2394
+ while (current != null) {
2395
+ if (seen.has(current)) {
2396
+ this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
2397
+ tags: { deviceId },
2398
+ meta: {
2399
+ cycleAt: current,
2400
+ chainSoFar: [...chain]
2401
+ }
2402
+ });
2403
+ return chain;
2404
+ }
2405
+ seen.add(current);
2406
+ chain.push(current);
2407
+ if (chain.length >= MAX_CHAIN_DEPTH) {
2408
+ this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
2409
+ tags: { deviceId },
2410
+ meta: {
2411
+ depth: chain.length,
2412
+ max: MAX_CHAIN_DEPTH
2413
+ }
2414
+ });
2415
+ break;
2416
+ }
2417
+ current = this.opts.getParentOf(current);
2418
+ }
2419
+ return chain;
2420
+ }
2421
+ };
2422
+ //#endregion
2404
2423
  //#region src/builtins/device-manager/device-link-cycle.ts
2405
2424
  function nodeKey(deviceId, cap) {
2406
2425
  return `${deviceId}:${cap}`;