@camstack/system 1.1.52 → 1.1.53

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 (51) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/device-manager/device-linked-devices.d.ts +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.js +1595 -1585
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +1595 -1585
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  22. package/dist/builtins/local-network/local-network.addon.js +1 -1
  23. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  24. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  25. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  26. package/dist/builtins/platform-probe/index.js +1 -1
  27. package/dist/builtins/platform-probe/index.mjs +1 -1
  28. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  29. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  30. package/dist/builtins/snapshot/index.js +1 -1
  31. package/dist/builtins/snapshot/index.mjs +1 -1
  32. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  33. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  34. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +11 -3
  35. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +11 -3
  36. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  37. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  38. package/dist/builtins/system-config/system-config.addon.js +1 -1
  39. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  40. package/dist/builtins/winston-logging/index.js +1 -1
  41. package/dist/builtins/winston-logging/index.mjs +1 -1
  42. package/dist/{dist-BduBhv6N.js → dist-BNro5CRu.js} +92 -16
  43. package/dist/{dist-B_PSvr-m.mjs → dist-D7_69kDt.mjs} +92 -16
  44. package/dist/index.js +22 -5
  45. package/dist/index.mjs +22 -6
  46. package/dist/kernel/index.d.ts +1 -1
  47. package/dist/kernel/moleculer/readiness-context.d.ts +12 -8
  48. package/dist/kernel/moleculer/readiness-service.d.ts +16 -1
  49. package/dist/{manifest-python-deps-CPAMH5gV.js → manifest-python-deps-DszE7bYn.js} +35 -9
  50. package/dist/{manifest-python-deps-CIyvuHAB.mjs → manifest-python-deps-EM2H-jjS.mjs} +35 -9
  51. package/package.json +1 -1
@@ -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-BNro5CRu.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,1742 @@ 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
  }
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
+ };
1274
+ }
1275
+ /**
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.
1280
+ */
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
1291
1289
  }));
1292
- return out;
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
+ }] };
1293
1321
  }
1294
1322
  /**
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.
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.
1327
+ */
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 };
1334
+ }
1335
+ //#endregion
1336
+ //#region src/builtins/device-manager/device-bindings-store.ts
1337
+ /**
1338
+ * Persisted wrapper-activation + per-device binding resolution for the
1339
+ * device-manager addon. Extracted from `device-manager.addon.ts` as free
1340
+ * functions that receive an explicit `BindingsDeps` context (dependency
1341
+ * injection) instead of closing over the addon instance — keeps the routing
1342
+ * logic cohesive and independently testable while preserving the EXACT call
1343
+ * semantics, ordering, and side effects of the original class methods.
1299
1344
  *
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)
1345
+ * The addon keeps thin delegator methods (`getBindings`, `getAllBindings`, …)
1346
+ * that build `BindingsDeps` from its own state and forward here, so every
1347
+ * existing internal + cap-router call site is unchanged.
1304
1348
  */
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
- }
1349
+ /**
1350
+ * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
1351
+ * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
1352
+ * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
1353
+ * full cluster view. Events from the local node are ignored: hub-local natives
1354
+ * live in `capabilityRegistry` and are folded in directly by getBindings.
1355
+ *
1356
+ * Push events are accurate in the steady state but can be lost during the
1357
+ * Moleculer transport handshake window (hub restart, crash-respawn,
1358
+ * restartAddon). The reliable replacement for lost events is the D3 re-handshake
1359
+ * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
1360
+ * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
1361
+ * handler purges a gone node's entries; the worker re-handshakes (and re-emits
1362
+ * `native-registered`) on its next boot.
1363
+ */
1364
+ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
1365
+ const localNodeId = ctx.kernel.localNodeId ?? "hub";
1366
+ ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (event) => {
1367
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
1368
+ if (nodeId === localNodeId) return;
1369
+ if (reason === "native-registered") {
1370
+ let perDevice = remoteNativeCaps.get(deviceId);
1371
+ if (!perDevice) {
1372
+ perDevice = /* @__PURE__ */ new Map();
1373
+ remoteNativeCaps.set(deviceId, perDevice);
1374
+ }
1375
+ perDevice.set(capName, {
1376
+ addonId,
1377
+ nodeId
1338
1378
  });
1379
+ } else if (reason === "native-unregistered") {
1380
+ const perDevice = remoteNativeCaps.get(deviceId);
1381
+ if (!perDevice) return;
1382
+ perDevice.delete(capName);
1383
+ if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
1339
1384
  }
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
1385
  });
1346
- return { status: "unavailable" };
1347
- }
1348
- /**
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.
1354
- */
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`);
1374
- }
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 };
1386
+ const cluster = ctx.kernel.cluster;
1387
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
1388
+ const gone = payload.node.id;
1389
+ const emptyDevices = [];
1390
+ for (const [deviceId, perDevice] of remoteNativeCaps) {
1391
+ const toDelete = [];
1392
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
1393
+ for (const capName of toDelete) perDevice.delete(capName);
1394
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
1382
1395
  }
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 }
1388
- });
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
- }
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 }
1396
+ for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
1405
1397
  });
1406
- return { success: true };
1407
1398
  }
1408
- /**
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
1424
- });
1399
+ async function readBindingsStore(deps) {
1400
+ return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
1401
+ }
1402
+ async function writeBindingsStore(deps, next) {
1403
+ await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
1404
+ }
1405
+ function resolveWrapperNodeId(_wrapperAddonId) {
1406
+ return "hub";
1425
1407
  }
1426
1408
  /**
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.
1409
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
1432
1410
  *
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).
1411
+ * Every addon runs in its own `addon-runner` with the composite node id
1412
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
1413
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
1414
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
1415
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
1416
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
1417
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
1418
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
1419
+ * to an unknown node `no-provider`, which surfaces as
1420
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
1421
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
1422
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
1442
1423
  *
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.
1424
+ * A flat node id (a genuine standalone node with no `/`) is returned
1425
+ * unchanged.
1446
1426
  */
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}`);
1427
+ function toRoutableProviderNodeId(nodeId) {
1428
+ const slash = nodeId.indexOf("/");
1429
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
1453
1430
  }
1454
1431
  /**
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.
1432
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
1433
+ * consulting the handshake-fed `HubNodeRegistry` via
1434
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
1435
+ * `remoteNativeCaps` cache misses covers the Moleculer transport
1436
+ * handshake window where `DeviceBindingsChanged` events were lost but the
1437
+ * D3 re-handshake (post device restore) has already populated the registry.
1459
1438
  *
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.
1439
+ * Returns `null` when the entry is genuinely not present in the cluster
1440
+ * view (cap not registered on any worker for that device).
1463
1441
  */
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 }
1474
- });
1475
- }
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)
1484
- });
1485
- }
1486
- return {
1487
- success: true,
1488
- failures
1442
+ function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
1443
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1444
+ if (!clusterCaps) return null;
1445
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
1446
+ addonId: entry.addonId,
1447
+ nodeId: entry.nodeId
1489
1448
  };
1449
+ return null;
1490
1450
  }
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;
1451
+ async function getBindings(deps, input) {
1452
+ const storeKey = String(input.deviceId);
1453
+ const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
1454
+ const entries = [];
1455
+ const seenCaps = /* @__PURE__ */ new Set();
1456
+ const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
1457
+ for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
1458
+ const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
1459
+ const remoteNative = resolveRemote(capName);
1460
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1461
+ const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
1462
+ if (wrapperAddonId === null && !nativeAddonId) {
1463
+ seenCaps.add(capName);
1464
+ continue;
1501
1465
  }
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
1466
+ entries.push({
1467
+ capName,
1468
+ kind: wrapperAddonId ? "wrapped" : "native",
1469
+ providerAddonId: wrapperAddonId ?? nativeAddonId,
1470
+ providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
1471
+ nativeAddonId
1507
1472
  });
1508
- return;
1473
+ seenCaps.add(capName);
1509
1474
  }
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;
1475
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
1476
+ if (seenCaps.has(capName)) continue;
1477
+ const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
1478
+ if (!defaultWrapperAddonId) continue;
1479
+ const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
1480
+ const remoteNative = resolveRemote(capName);
1481
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1482
+ entries.push({
1483
+ capName,
1484
+ kind: "wrapped",
1485
+ providerAddonId: defaultWrapperAddonId,
1486
+ providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
1487
+ nativeAddonId
1488
+ });
1489
+ seenCaps.add(capName);
1519
1490
  }
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)
1491
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
1492
+ if (seenCaps.has(capName)) continue;
1493
+ const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
1494
+ entries.push({
1495
+ capName,
1496
+ kind: "native",
1497
+ providerAddonId: nativeAddonId,
1498
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1499
+ nativeAddonId
1500
+ });
1501
+ seenCaps.add(capName);
1502
+ }
1503
+ const pushFed = deps.remoteNativeCaps.get(input.deviceId);
1504
+ if (pushFed) for (const [capName, info] of pushFed) {
1505
+ if (seenCaps.has(capName)) continue;
1506
+ entries.push({
1507
+ capName,
1508
+ kind: "native",
1509
+ providerAddonId: info.addonId,
1510
+ providerNodeId: toRoutableProviderNodeId(info.nodeId),
1511
+ nativeAddonId: info.addonId
1512
+ });
1513
+ seenCaps.add(capName);
1514
+ }
1515
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1516
+ if (clusterCaps) for (const entry of clusterCaps) {
1517
+ if (entry.deviceId !== input.deviceId) continue;
1518
+ if (seenCaps.has(entry.capName)) continue;
1519
+ if (!entry.addonId) continue;
1520
+ const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
1521
+ if (entry.nodeId === localNodeId) continue;
1522
+ entries.push({
1523
+ capName: entry.capName,
1524
+ kind: "native",
1525
+ providerAddonId: entry.addonId,
1526
+ providerNodeId: toRoutableProviderNodeId(entry.nodeId),
1527
+ nativeAddonId: entry.addonId
1528
+ });
1529
+ seenCaps.add(entry.capName);
1530
+ }
1531
+ if (deps.devicesWithLinks?.has(input.deviceId)) {
1532
+ const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
1533
+ for (const link of row?.deviceLinks ?? []) {
1534
+ const capName = link.target.cap;
1535
+ if (seenCaps.has(capName)) continue;
1536
+ entries.push({
1537
+ capName,
1538
+ kind: "linked",
1539
+ providerAddonId: deps.ctx.id,
1540
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1541
+ nativeAddonId: ""
1542
+ });
1543
+ seenCaps.add(capName);
1565
1544
  }
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;
1545
+ }
1588
1546
  return {
1589
- cap: capName,
1590
- fields,
1591
- ...itemArray ? { itemArray: {
1592
- path: itemArray.path,
1593
- keyField: itemArray.keyField
1594
- } } : {}
1547
+ deviceId: input.deviceId,
1548
+ entries
1595
1549
  };
1596
1550
  }
1597
1551
  /**
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).
1552
+ * Whole-fleet binding dump. Iterates every device known to the
1553
+ * deviceRegistry and reuses the per-device `getBindings` resolver
1554
+ * for each same routing rules, single round-trip. Used by
1555
+ * `SystemManager.init()` for warm-boot.
1603
1556
  *
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.
1557
+ * Bindings change rarely (wrapper toggle, device add/remove) so
1558
+ * clients invalidate via the existing
1559
+ * `capability.binding-changed` event rather than re-fetching this
1560
+ * payload periodically.
1610
1561
  */
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 };
1562
+ async function getAllBindings(deps) {
1563
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1564
+ if (!hubRegistry) return [];
1565
+ const out = [];
1566
+ for (const device of hubRegistry.getAll()) out.push(await getBindings(deps, { deviceId: device.id }));
1567
+ return out;
1568
+ }
1569
+ /**
1570
+ * Resolve a numeric deviceId to a stableId via persisted meta.
1571
+ * Used only by the device-identity section of the device-details
1572
+ * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
1573
+ * a readonly display field. All runtime/registry lookups are keyed by
1574
+ * numeric deviceId; this helper is display-only.
1575
+ */
1576
+ async function lookupPersistedStableId(deps, deviceId) {
1577
+ return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
1639
1578
  }
1640
1579
  //#endregion
1641
- //#region src/builtins/device-manager/device-event-propagator.ts
1580
+ //#region src/builtins/device-manager/device-aggregation.ts
1642
1581
  /**
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.
1582
+ * Device-details aggregation + field-update routing for the device-manager
1583
+ * addon. Extracted from `device-manager.addon.ts` as free functions that
1584
+ * receive an explicit `AggregationDeps` context (DI) rather than closing over
1585
+ * the addon instance.
1663
1586
  *
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}] }
1587
+ * This module owns the binding-driven settings/live/status aggregators
1588
+ * (`getDeviceAggregate`, `collectSystemDeviceContributions`,
1589
+ * `deriveDeviceConfigContribution`, `buildBaseDeviceSection`,
1590
+ * `getDeviceStatusAggregate`, `resolveDriverConfigSchema`) and the
1591
+ * field-update routing (`updateDeviceField`, `updateDeviceFieldsBatch`,
1592
+ * `applyGroupPatch`, `resolveContributionProvider`, `dispatchDeviceAction`)
1593
+ * plus the wrapper-activation mutators (`setWrapperActive`,
1594
+ * `listWrappersForCap`, `listBindableCapsForDeviceType`).
1668
1595
  *
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.
1596
+ * Sibling aggregator functions call each other directly (passing `deps`);
1597
+ * cross-domain leaf calls (`getBindings`, `lookupPersistedStableId`,
1598
+ * `resolveRemoteNativeCapFromRegistry`, read/writeBindingsStore) route through
1599
+ * the bindings-store module via `deps.bindingsDeps`. EXACT semantics, ordering,
1600
+ * and side effects of the original class methods are preserved; the addon keeps
1601
+ * thin delegator methods over these functions.
1672
1602
  */
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;
1603
+ async function getDeviceAggregate(deps, deviceId, kind) {
1604
+ const registry = deps.capabilityRegistry;
1605
+ if (!registry) {
1606
+ deps.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
1607
+ return null;
1690
1608
  }
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
1609
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
1610
+ const { entries: bindingEntries } = await getBindings(deps.bindingsDeps, { deviceId });
1611
+ const seenCaps = new Set(bindingEntries.map((e) => e.capName));
1612
+ const results = await Promise.all(bindingEntries.map(async (entry) => {
1613
+ const def = registry.getDefinition(entry.capName);
1614
+ if (require_dist.isDeviceConfigCap(def)) return deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind);
1615
+ if (!def?.exposesDeviceSettings) return null;
1616
+ const provider = registry.getProviderByAddon(entry.capName, entry.providerAddonId);
1617
+ if (!provider) return null;
1618
+ try {
1619
+ const contribution = await provider[method]({ deviceId });
1620
+ if (!contribution) return null;
1621
+ return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
1622
+ } catch (err) {
1623
+ const msg = err instanceof Error ? err.message : String(err);
1624
+ deps.ctx.logger.warn("contribution method failed", {
1625
+ tags: {
1626
+ deviceId,
1627
+ addonId: entry.providerAddonId
1707
1628
  },
1708
- via: [...via]
1709
- };
1710
- this.opts.eventBus.emit(reEmission);
1711
- via.push({
1712
- type: "device",
1713
- id: ancestorId
1629
+ meta: {
1630
+ capName: entry.capName,
1631
+ method,
1632
+ error: msg
1633
+ }
1714
1634
  });
1635
+ return null;
1715
1636
  }
1716
- }
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 },
1637
+ }));
1638
+ const base = kind === "settings" ? await buildBaseDeviceSection(deps, deviceId) : null;
1639
+ const systemResults = await collectSystemDeviceContributions(deps, deviceId, kind, seenCaps);
1640
+ const parts = [
1641
+ ...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [],
1642
+ ...results.filter((r) => r !== null),
1643
+ ...systemResults
1644
+ ];
1645
+ if (parts.length === 0) return null;
1646
+ return mergeAggregates(parts);
1647
+ }
1648
+ /**
1649
+ * System-scoped per-device contributions — the deliberate exception to
1650
+ * binding-driven aggregation (D12).
1651
+ *
1652
+ * A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
1653
+ * state but is NOT bound per-device: it is a system addon, not a BaseDevice,
1654
+ * so it never calls `registerNativeCap` and never appears in
1655
+ * `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
1656
+ * per-device panel never renders. Two such caps ship today:
1657
+ * - `stream-broker` (singleton) — the RTSP-restream / pre-buffer settings
1658
+ * tab, scoped to the cameras whose streams it serves.
1659
+ * - `device-export` (collection) — the "Export" panel; a device can be
1660
+ * exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
1661
+ *
1662
+ * D12 bans fanning a per-device call out to every same-named provider of a
1663
+ * `scope: 'device'` cap, because vendor providers SHARE cap names
1664
+ * (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
1665
+ * vendor-shared — a system cap name maps to one active provider (singleton)
1666
+ * or one declared collection — so consulting the cap's own provider(s)
1667
+ * directly is safe. Each provider gates internally by deviceId and returns
1668
+ * null for devices it does not own.
1669
+ *
1670
+ * `seenCaps` skips any system cap that already contributed via a binding so
1671
+ * its section is never duplicated.
1672
+ */
1673
+ async function collectSystemDeviceContributions(deps, deviceId, kind, seenCaps) {
1674
+ const registry = deps.capabilityRegistry;
1675
+ if (!registry) return [];
1676
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
1677
+ const tasks = [];
1678
+ for (const info of registry.listCapabilities()) {
1679
+ const def = registry.getDefinition(info.name);
1680
+ if (!def || def.scope !== "system" || !def.exposesDeviceSettings) continue;
1681
+ if (seenCaps.has(info.name)) continue;
1682
+ const addonIds = def.mode === "collection" ? info.providers : info.activeProvider ? [info.activeProvider] : [];
1683
+ for (const addonId of addonIds) tasks.push((async () => {
1684
+ const provider = registry.getProviderByAddon(info.name, addonId);
1685
+ if (!provider) return null;
1686
+ try {
1687
+ const contribution = await provider[method]({ deviceId });
1688
+ if (!contribution) return null;
1689
+ return tagContribution(toWireShape(contribution), info.name, addonId, kind);
1690
+ } catch (err) {
1691
+ deps.ctx.logger.warn("system device-settings contribution skipped", {
1692
+ tags: {
1693
+ deviceId,
1694
+ addonId
1695
+ },
1737
1696
  meta: {
1738
- depth: chain.length,
1739
- max: MAX_CHAIN_DEPTH
1697
+ capName: info.name,
1698
+ method,
1699
+ error: err instanceof Error ? err.message : String(err)
1740
1700
  }
1741
1701
  });
1742
- break;
1702
+ return null;
1743
1703
  }
1744
- current = this.opts.getParentOf(current);
1745
- }
1746
- return chain;
1704
+ })());
1747
1705
  }
1748
- };
1749
- //#endregion
1750
- //#region src/builtins/device-manager/device-queries.ts
1706
+ return (await Promise.all(tasks)).filter((r) => r !== null);
1707
+ }
1751
1708
  /**
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.
1709
+ * D14: framework-derived device-config contribution.
1758
1710
  *
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).
1711
+ * `kind === 'live'` device-config caps contribute nothing to the live
1712
+ * aggregate (they hold editable config, not live observables).
1766
1713
  *
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 refactor no behavior change.
1714
+ * `ui.kind === 'widget'` emits a single structural `type:'widget'`
1715
+ * section; the widget self-persists via the cap's own mutations.
1716
+ *
1717
+ * `ui.kind === 'derived-form'` calls `getOptions`/`getStatus` on the
1718
+ * bound provider, runs the registered pure builder, and returns the
1719
+ * derived form sections. Returns null when the camera exposes nothing
1720
+ * configurable or the provider is not yet registered.
1772
1721
  */
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
- }
1722
+ async function deriveDeviceConfigContribution(deps, registry, def, entry, deviceId, kind) {
1723
+ if (kind === "live") return null;
1724
+ const ui = def.deviceConfig.ui;
1725
+ if (ui.kind === "widget") return tagContribution({ sections: [{
1726
+ id: `${entry.capName}-widget`,
1727
+ title: ui.label,
1728
+ tab: ui.tab,
1729
+ order: ui.order ?? 0,
1730
+ ...ui.topTab ? { location: "top-tab" } : {},
1731
+ fields: [{
1732
+ type: "widget",
1733
+ key: `${entry.capName}Widget`,
1734
+ label: ui.label,
1735
+ widgetId: ui.widgetId
1736
+ }]
1737
+ }] }, entry.capName, entry.providerAddonId, kind);
1738
+ const provider = registry.getProviderForDevice(entry.capName, deviceId);
1739
+ if (!provider || typeof provider.getOptions !== "function") return null;
1740
+ try {
1741
+ const options = await provider.getOptions({ deviceId });
1742
+ const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
1743
+ const derived = deriveFormContribution(ui.builderId, options, status ?? null);
1744
+ if (!derived) return null;
1745
+ return tagContribution({
1746
+ ...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
1747
+ sections: derived.sections.map((s) => ({
1748
+ id: s.id,
1749
+ title: s.title,
1750
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
1751
+ ...s.order !== void 0 ? { order: s.order } : {},
1752
+ ...s.description !== void 0 ? { description: s.description } : {},
1753
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
1754
+ fields: [...s.fields]
1755
+ }))
1756
+ }, entry.capName, entry.providerAddonId, kind);
1757
+ } catch (err) {
1758
+ const msg = err instanceof Error ? err.message : String(err);
1759
+ deps.ctx.logger.warn("device-config derivation failed", {
1760
+ tags: {
1761
+ deviceId,
1762
+ addonId: entry.providerAddonId
1763
+ },
1764
+ meta: {
1765
+ capName: entry.capName,
1766
+ error: msg
1767
+ }
1768
+ });
1769
+ return null;
1810
1770
  }
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
1771
  }
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);
1772
+ /**
1773
+ * Build the device-manager's own contribution to the aggregator — the
1774
+ * device identity (id, stableId, addonId, type, online) + the
1775
+ * driver-specific config exposed by the device class via
1776
+ * `zodEntriesToConfigUI`.
1777
+ *
1778
+ * Two paths, deliberately symmetric with `getSettingsSchema`:
1779
+ *
1780
+ * - Hub-local: device's IDevice instance lives in this process'
1781
+ * DeviceRegistry, we read config + schema directly by reference.
1782
+ * - Cross-process: device lives in a forked worker (RtspCamera on
1783
+ * provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
1784
+ * `device-ops.getSettingsSchema` native provider for a wire-
1785
+ * serializable ConfigUISchema and merge it in under the same
1786
+ * "Driver Config" section, so the UI sees the same shape regardless
1787
+ * of where the IDevice physically runs.
1788
+ *
1789
+ * Returns `null` only when the device genuinely doesn't exist anywhere
1790
+ * (no hub-local, no persisted ownership, no device-ops native). The
1791
+ * aggregator falls back to contributor sections only in that case.
1792
+ */
1793
+ async function buildBaseDeviceSection(deps, deviceId) {
1794
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
1795
+ const hubLocal = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1796
+ const stableId = hubLocal?.device.stableId ?? await lookupPersistedStableId(deps.bindingsDeps, deviceId);
1797
+ const nativeOwner = resolveNativeDeviceOwner(deps, deviceId);
1798
+ const addonId = hubLocal?.addonId ?? nativeOwner?.addonId ?? null;
1799
+ if (!hubLocal && !nativeOwner) return null;
1800
+ const sections = [{
1801
+ id: "device-identity",
1802
+ title: "Identity",
1803
+ tab: "general",
1804
+ order: 0,
1805
+ fields: [
1806
+ {
1807
+ type: "text",
1808
+ key: "_deviceId",
1809
+ label: "Device ID",
1810
+ readonlyField: true,
1811
+ value: String(deviceId)
1812
+ },
1813
+ {
1814
+ type: "text",
1815
+ key: "_stableId",
1816
+ label: "Stable ID",
1817
+ readonlyField: true,
1818
+ value: stableId ?? ""
1819
+ },
1820
+ {
1821
+ type: "text",
1822
+ key: "_addonId",
1823
+ label: "Driver",
1824
+ readonlyField: true,
1825
+ value: addonId ?? "unknown"
1826
+ },
1827
+ ...hubLocal ? [{
1828
+ type: "text",
1829
+ key: "_type",
1830
+ label: "Type",
1831
+ readonlyField: true,
1832
+ value: hubLocal.device.type
1833
+ }, {
1834
+ type: "text",
1835
+ key: "_online",
1836
+ label: "Online",
1837
+ readonlyField: true,
1838
+ value: hubLocal.device.online ? "yes" : "no"
1839
+ }] : []
1840
+ ]
1841
+ }];
1842
+ const driverResult = await resolveDriverConfigSchema(deps, deviceId, hubLocal);
1843
+ if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
1844
+ id: section.id,
1845
+ title: section.title,
1846
+ tab: section.tab ?? "general",
1847
+ order: section.order ?? 1,
1848
+ fields: [...section.fields],
1849
+ ...section.description !== void 0 ? { description: section.description } : {},
1850
+ ...section.columns !== void 0 ? { columns: section.columns } : {}
1851
+ });
1852
+ else if (driverResult.status === "unavailable") sections.push({
1853
+ id: "driver-settings-unavailable",
1854
+ title: "Driver Settings",
1855
+ tab: "general",
1856
+ order: 1,
1857
+ fields: [{
1858
+ type: "info",
1859
+ key: "driver-settings-unavailable-notice",
1860
+ label: "Temporarily unavailable",
1861
+ content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
1862
+ variant: "warning"
1863
+ }]
1864
+ });
1871
1865
  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 } : {}
1866
+ sections,
1867
+ ...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
1894
1868
  };
1895
1869
  }
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
- });
1958
- }
1959
- return results;
1960
- }
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) => ({
1968
- 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();
2001
- }
2002
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
2003
- return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
1870
+ /**
1871
+ * Lookup the native owner for `device-ops` on `deviceId` — the native-cap
1872
+ * registry (hub-local and remote) is keyed by numeric id.
1873
+ */
1874
+ function resolveNativeDeviceOwner(deps, deviceId) {
1875
+ const local = deps.capabilityRegistry?.getNativeAddonId("device-ops", deviceId) ?? null;
1876
+ if (local) return {
1877
+ addonId: local,
1878
+ nodeId: deps.ctx.kernel.localNodeId ?? "hub"
1879
+ };
1880
+ const remote = deps.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? resolveRemoteNativeCapFromRegistry(deps.bindingsDeps, "device-ops", deviceId);
1881
+ return remote ? {
1882
+ addonId: remote.addonId,
1883
+ nodeId: remote.nodeId
1884
+ } : null;
2004
1885
  }
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
- }
1886
+ /**
1887
+ * Aggregate `status` across every registered cap for a device.
1888
+ *
1889
+ * Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
1890
+ * omitted), looks up a native provider per cap via the capability
1891
+ * registry, calls `provider.getStatus({ deviceId })`, and validates
1892
+ * the return against the cap's own `status.schema`. Validation
1893
+ * failures log a warning and yield `null` for that cap so the
1894
+ * overall aggregate stays usable — a single misbehaving provider
1895
+ * must not blank out a device's entire status view.
1896
+ *
1897
+ * Returned shape is `Record<capName, unknown | null>`; the client-
1898
+ * side hook tightens this to `CapStatusTypeMap` via the generated
1899
+ * `cap-status-types.ts`.
1900
+ */
1901
+ async function getDeviceStatusAggregate(deps, input) {
1902
+ const capNames = input.caps ?? require_dist.CAP_NAMES_WITH_STATUS;
1903
+ const registry = deps.capabilityRegistry;
1904
+ const out = {};
1905
+ if (!registry) {
1906
+ for (const name of capNames) out[name] = null;
1907
+ return out;
2013
1908
  }
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
2018
- });
2019
- return { success: true };
2020
- }
2021
- async function enable(pctx, input) {
2022
- await pctx.provider.setDisabled({
2023
- deviceId: input.deviceId,
2024
- disabled: false
2025
- });
2026
- return { success: true };
2027
- }
2028
- async function disable(pctx, input) {
2029
- await pctx.provider.setDisabled({
2030
- deviceId: input.deviceId,
2031
- disabled: true
2032
- });
2033
- return { success: true };
2034
- }
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
- } });
1909
+ await Promise.all(capNames.map(async (capName) => {
1910
+ try {
1911
+ const def = registry.getDefinition(capName);
1912
+ if (!def?.status) {
1913
+ out[capName] = null;
1914
+ return;
1915
+ }
1916
+ const provider = registry.getProviderForDevice(capName, input.deviceId);
1917
+ if (!provider || typeof provider.getStatus !== "function") {
1918
+ out[capName] = null;
2049
1919
  return;
2050
1920
  }
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 });
1921
+ const raw = await provider.getStatus({ deviceId: input.deviceId });
1922
+ if (raw == null) {
1923
+ out[capName] = null;
1924
+ return;
1925
+ }
1926
+ const parsed = def.status.schema.safeParse(raw);
1927
+ if (!parsed.success) {
1928
+ deps.ctx.logger.warn("getDeviceStatusAggregate: provider returned invalid status, dropping", {
1929
+ tags: { deviceId: input.deviceId },
1930
+ meta: {
1931
+ capName,
1932
+ issues: parsed.error.issues.slice(0, 3)
1933
+ }
1934
+ });
1935
+ out[capName] = null;
1936
+ return;
1937
+ }
1938
+ out[capName] = parsed.data;
2057
1939
  } 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) }
1940
+ deps.ctx.logger.warn("getDeviceStatusAggregate: provider threw, dropping", {
1941
+ tags: { deviceId: input.deviceId },
1942
+ meta: {
1943
+ capName,
1944
+ error: require_dist.errMsg(err)
1945
+ }
2064
1946
  });
1947
+ out[capName] = null;
2065
1948
  }
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 };
1949
+ }));
1950
+ return out;
2074
1951
  }
2075
1952
  /**
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.
1953
+ * Return the driver-specific device-settings contribution. Hub-local
1954
+ * devices call `getSettingsUISchema()` directly; forked-worker devices
1955
+ * go through the `device-ops.getSettingsSchema` cap method on the
1956
+ * numeric-id-keyed native registry.
1957
+ *
1958
+ * Returns a discriminated result so callers can distinguish three states:
1959
+ * 'ok' – schema obtained successfully
1960
+ * 'none' – driver genuinely has no settings schema
1961
+ * 'unavailable' – worker was unreachable after retries (transient)
2082
1962
  */
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
- }
1963
+ async function resolveDriverConfigSchema(deps, deviceId, hubLocal) {
1964
+ if (hubLocal) {
1965
+ const schema = hubLocal.device.getSettingsUISchema();
1966
+ return schema.sections.length === 0 ? { status: "none" } : {
1967
+ status: "ok",
1968
+ schema: toWireShape(schema)
1969
+ };
2119
1970
  }
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;
1971
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1972
+ if (!ops) return { status: "none" };
1973
+ const MAX_RETRIES = 2;
1974
+ const RETRY_DELAY_MS = 750;
1975
+ let lastErr;
1976
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1977
+ if (attempt > 0) await require_dist.sleep(RETRY_DELAY_MS);
2134
1978
  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
- });
1979
+ const schema = await ops.getSettingsSchema({ deviceId });
1980
+ if (!schema) return { status: "none" };
1981
+ const wire = schema;
1982
+ return wire.sections.length === 0 ? { status: "none" } : {
1983
+ status: "ok",
1984
+ schema: toWireShape(wire)
1985
+ };
2144
1986
  } 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 });
1987
+ lastErr = err;
1988
+ if (!isTransientMoleculerError(err)) break;
1989
+ if (attempt < MAX_RETRIES) deps.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
1990
+ tags: { deviceId },
1991
+ meta: {
1992
+ attempt: attempt + 1,
1993
+ maxRetries: MAX_RETRIES,
1994
+ error: require_dist.errMsg(err)
1995
+ }
1996
+ });
2151
1997
  }
2152
1998
  }
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
- }));
1999
+ const msg = require_dist.errMsg(lastErr);
2000
+ deps.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
2001
+ tags: { deviceId },
2002
+ meta: { error: msg }
2003
+ });
2004
+ return { status: "unavailable" };
2164
2005
  }
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) }
2006
+ /**
2007
+ * Route a `derived-form` device-config cap's flat form patch to whichever
2008
+ * mutation the bound provider actually exposes inspecting the
2009
+ * provider's method surface, not the cap name. `setSettings` fires once
2010
+ * with the parsed flat patch; `setProfile` fans the patch out per-profile
2011
+ * via the registered builderId reducer.
2012
+ */
2013
+ async function dispatchDerivedFormSave(builderId, deviceId, patch, dcProvider, capName) {
2014
+ if (typeof dcProvider.setSettings === "function") {
2015
+ const settings = parseDerivedFormSettingsPatch(builderId, patch);
2016
+ await dcProvider.setSettings({
2017
+ deviceId,
2018
+ settings
2178
2019
  });
2020
+ return;
2179
2021
  }
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;
2022
+ if (typeof dcProvider.setProfile === "function") {
2023
+ const setProfile = dcProvider.setProfile;
2024
+ await applyDerivedFormProfilePatch(builderId, patch, (profile, profilePatch) => setProfile({
2025
+ deviceId,
2026
+ profile,
2027
+ patch: profilePatch
2028
+ }));
2029
+ return;
2030
+ }
2031
+ throw new Error(`[device-manager] device-config cap "${capName}" provider exposes neither setSettings nor setProfile`);
2186
2032
  }
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
2193
- });
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) }
2033
+ async function updateDeviceField(deps, input) {
2034
+ if (input.writerCapName === "device-manager") {
2035
+ if (input.key === "_linkedDevicesMode" || input.key === "_linkedDeviceIds") {
2036
+ const settings = deps.ctx.settings;
2037
+ if (!settings) throw new Error("[device-manager] linked-devices save requires the settings surface");
2038
+ await applyLinkedDevicesPatch({ settings }, input.deviceId, { [input.key]: input.value });
2039
+ return { success: true };
2040
+ }
2041
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
2042
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, input.deviceId) : null;
2043
+ if (found) {
2044
+ await found.device.applySettingsPatch({ [input.key]: input.value });
2045
+ return { success: true };
2046
+ }
2047
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", input.deviceId);
2048
+ if (!ops) throw new Error(`[device-manager] device "${input.deviceId}" not found (no hub-local entry, no device-ops native provider)`);
2049
+ await ops.setConfig({
2050
+ deviceId: input.deviceId,
2051
+ values: { [input.key]: input.value }
2203
2052
  });
2053
+ return { success: true };
2054
+ }
2055
+ const registry = deps.capabilityRegistry;
2056
+ if (!registry) throw new Error("[device-manager] updateDeviceField requires capability registry — unavailable on this node");
2057
+ const def = registry.getDefinition(input.writerCapName);
2058
+ if (require_dist.isDeviceConfigCap(def)) {
2059
+ if (def.deviceConfig.ui.kind === "widget") return { success: true };
2060
+ const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
2061
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
2062
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, input.deviceId, { [input.key]: input.value }, dcProvider, input.writerCapName);
2063
+ return { success: true };
2204
2064
  }
2205
- return summary;
2206
- }
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 } : {}
2065
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
2066
+ await resolveContributionProvider(registry, def, input.writerCapName, input.writerAddonId, input.deviceId).applyDeviceSettingsPatch({
2067
+ deviceId: input.deviceId,
2068
+ patch: { [input.key]: input.value }
2213
2069
  });
2070
+ return { success: true };
2214
2071
  }
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);
2072
+ /**
2073
+ * Dispatch a device custom action. Hub-local devices run it directly;
2074
+ * forked/remote devices route through the `device-ops` native cap
2075
+ * (`runAction`) — the same hub-local-then-device-ops fan-out that
2076
+ * `updateDeviceField` uses for `applySettingsPatch`.
2077
+ */
2078
+ async function dispatchDeviceAction(deps, deviceId, action, input) {
2079
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
2080
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
2081
+ if (found) return found.device.runDeviceAction(action, input);
2082
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
2083
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found for runDeviceAction (no hub-local entry, no device-ops native provider)`);
2084
+ return ops.runAction({
2085
+ deviceId,
2086
+ action,
2087
+ input
2088
+ });
2226
2089
  }
2227
- async function adoptionRelease(pctx, input) {
2228
- const { addonId, ...rest } = input;
2229
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
2090
+ /**
2091
+ * Resolve the `DeviceSettingsContribution` provider that owns a tagged
2092
+ * field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
2093
+ * from the binding that produced it — for `kind:'wrapped'` entries, that is
2094
+ * the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
2095
+ * the system addon id.
2096
+ *
2097
+ * Resolution order (Bug-3 fix):
2098
+ * 1. `getProviderByAddon(capName, writerAddonId)` — resolves the
2099
+ * system-registered provider by the addon id from the tagged field.
2100
+ * For wrapper bindings (snapshot, motion-detection, etc.) this directly
2101
+ * returns the wrapper provider, bypassing the native-first resolution
2102
+ * order of `getProviderForDevice` that caused "method not found".
2103
+ * 2. `getSingleton(capName)` — fallback for stale/mismatched writerAddonIds.
2104
+ * The active singleton handles the contribution even if the addonId
2105
+ * stored in the field is out of date (e.g. after an addon rename).
2106
+ *
2107
+ * `getProviderForDevice` is intentionally NOT used here: it returns the
2108
+ * per-device native first when present (e.g. Reolink/ONVIF), and the native
2109
+ * does NOT implement contribution methods — the root cause of Bug-3.
2110
+ */
2111
+ function resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
2112
+ const byAddon = registry.getProviderByAddon(writerCapName, writerAddonId);
2113
+ if (byAddon) return byAddon;
2114
+ const singleton = registry.getSingleton(writerCapName);
2115
+ if (singleton) return singleton;
2116
+ throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
2230
2117
  }
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
- } });
2118
+ /**
2119
+ * Batched counterpart of `updateDeviceField`. Groups changes by
2120
+ * `(writerCapName, writerAddonId)` so each contributor receives a
2121
+ * single `applyDeviceSettingsPatch` with all of its updates merged —
2122
+ * avoids N round-trips for simultaneous edits in the same save.
2123
+ *
2124
+ * Per-provider failures are captured in the `failures[]` output so the
2125
+ * admin UI can highlight which sections didn't persist; a failure on
2126
+ * one provider does NOT abort the others.
2127
+ */
2128
+ async function updateDeviceFieldsBatch(deps, input) {
2129
+ const groups = /* @__PURE__ */ new Map();
2130
+ for (const change of input.changes) {
2131
+ const key = `${change.writerCapName}::${change.writerAddonId}`;
2132
+ const existing = groups.get(key);
2133
+ if (existing) existing.patch[change.key] = change.value;
2134
+ else groups.set(key, {
2135
+ writerCapName: change.writerCapName,
2136
+ writerAddonId: change.writerAddonId,
2137
+ patch: { [change.key]: change.value }
2138
+ });
2139
+ }
2140
+ const failures = [];
2141
+ for (const group of groups.values()) try {
2142
+ await applyGroupPatch(deps, input.deviceId, group);
2143
+ } catch (err) {
2144
+ failures.push({
2145
+ writerCapName: group.writerCapName,
2146
+ writerAddonId: group.writerAddonId,
2147
+ error: err instanceof Error ? err.message : String(err)
2148
+ });
2247
2149
  }
2248
2150
  return {
2249
- ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
2250
- camDeviceId,
2251
- resetToSource
2252
- }),
2253
- removedChildren
2151
+ success: true,
2152
+ failures
2254
2153
  };
2255
2154
  }
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
2155
+ /** Apply a single grouped patch to the appropriate provider. Mirrors
2156
+ * `updateDeviceField` routing (special-case device-manager, else
2157
+ * registry lookup). Used by `updateDeviceFieldsBatch`. */
2158
+ async function applyGroupPatch(deps, deviceId, group) {
2159
+ if (group.writerCapName === "device-manager") {
2160
+ const hubRegistry = deps.ctx.kernel?.deviceRegistry;
2161
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
2162
+ if (found) {
2163
+ await found.device.applySettingsPatch(group.patch);
2164
+ return;
2165
+ }
2166
+ const ops = deps.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
2167
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found (no hub-local entry, no device-ops native provider)`);
2168
+ await ops.setConfig({
2169
+ deviceId,
2170
+ values: group.patch
2171
+ });
2172
+ return;
2173
+ }
2174
+ const registry = deps.capabilityRegistry;
2175
+ if (!registry) throw new Error("[device-manager] capability registry unavailable");
2176
+ const def = registry.getDefinition(group.writerCapName);
2177
+ if (require_dist.isDeviceConfigCap(def)) {
2178
+ if (def.deviceConfig.ui.kind === "widget") return;
2179
+ const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
2180
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
2181
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, deviceId, group.patch, dcProvider, group.writerCapName);
2182
+ return;
2183
+ }
2184
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
2185
+ await resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
2186
+ deviceId,
2187
+ patch: group.patch
2277
2188
  });
2278
2189
  }
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
- };
2190
+ async function listWrappersForCap(deps, input) {
2191
+ return [...deps.capabilityRegistry?.getWrappersForCap(input.capName) ?? []];
2299
2192
  }
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);
2193
+ async function listBindableCapsForDeviceType(deps, input) {
2194
+ const registry = deps.capabilityRegistry;
2195
+ if (!registry) return [];
2196
+ return registry.listDeviceScopedCapsForType(input.deviceType).map((capName) => ({
2197
+ capName,
2198
+ wrappers: [...registry.getWrappersForCap(capName)]
2199
+ }));
2200
+ }
2201
+ async function setWrapperActive(deps, input) {
2202
+ const storeKey = String(input.deviceId);
2203
+ const store = await readBindingsStore(deps.bindingsDeps);
2204
+ const perDevice = { ...store.deviceBindings[storeKey] };
2205
+ if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
2206
+ else perDevice[input.capName] = { wrapperAddonId: null };
2207
+ const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
2208
+ ...store.deviceBindings,
2209
+ [storeKey]: perDevice
2210
+ } : (() => {
2211
+ const { [storeKey]: _drop, ...rest } = store.deviceBindings;
2212
+ return rest;
2213
+ })();
2214
+ await writeBindingsStore(deps.bindingsDeps, { deviceBindings: nextDeviceBindings });
2215
+ deps.ctx.eventBus.emit({
2216
+ id: (0, node_crypto.randomUUID)(),
2217
+ timestamp: /* @__PURE__ */ new Date(),
2218
+ source: {
2219
+ type: "addon",
2220
+ id: deps.ctx.id
2221
+ },
2222
+ category: require_dist.EventCategory.DeviceBindingsChanged,
2223
+ data: {
2224
+ deviceId: input.deviceId,
2225
+ capName: input.capName,
2226
+ reason: input.active ? "wrapper-activated" : "wrapper-deactivated",
2227
+ addonId: input.wrapperAddonId,
2228
+ nodeId: resolveWrapperNodeId(input.wrapperAddonId)
2311
2229
  }
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
2230
  });
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
2231
  }
2347
2232
  /**
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.
2233
+ * Build the wireable-cap entry for one capability definition: the status
2234
+ * schema's scalar leaf fields plus for an item-array cap
2235
+ * (`status.itemArray`) the per-item fields tagged `item: true` (a link
2236
+ * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
2237
+ * descriptor so the UI can address items. Returns null when the cap exposes
2238
+ * nothing wireable (no status schema / no leaf fields).
2352
2239
  */
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
2240
+ function wireableEntryForDef(capName, def) {
2241
+ const status = def.status;
2242
+ const schema = status?.schema;
2243
+ if (!schema) return null;
2244
+ const itemArray = status.itemArray;
2245
+ const fields = [...require_dist.enumerateSchemaFields(schema), ...itemArray ? require_dist.enumerateItemArrayFields(itemArray) : []].map((f) => ({
2246
+ path: f.path,
2247
+ kind: f.kind,
2248
+ ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
2249
+ ...f.item === true ? { item: true } : {}
2361
2250
  }));
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
- }] };
2251
+ if (fields.length === 0) return null;
2252
+ return {
2253
+ cap: capName,
2254
+ fields,
2255
+ ...itemArray ? { itemArray: {
2256
+ path: itemArray.path,
2257
+ keyField: itemArray.keyField
2258
+ } } : {}
2259
+ };
2389
2260
  }
2390
2261
  /**
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.
2262
+ * Per-device wireable-field catalog the domain status fields a device's
2263
+ * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
2264
+ * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
2265
+ * internal book-keeping, not wireable domain data), and enumerates each cap's
2266
+ * status schema leaf fields (per-item fields included for item-array caps).
2267
+ *
2268
+ * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
2269
+ * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
2270
+ * marker) and whose `deviceTypes` admit this device's persisted type — so the
2271
+ * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
2272
+ * authored before any binding exists. Default (absent/false) behavior is
2273
+ * byte-identical to the binding-driven catalog.
2395
2274
  */
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 };
2275
+ async function getWireableFields(deps, input) {
2276
+ const { deviceId } = input;
2277
+ const reg = deps.capabilityRegistry;
2278
+ if (!reg) return { caps: [] };
2279
+ const { entries } = await getBindings(deps.bindingsDeps, { deviceId });
2280
+ const seen = /* @__PURE__ */ new Set();
2281
+ const caps = [];
2282
+ for (const entry of entries) {
2283
+ if (seen.has(entry.capName)) continue;
2284
+ seen.add(entry.capName);
2285
+ const def = reg.getDefinition(entry.capName);
2286
+ if (!def || def.kind === "wrapper") continue;
2287
+ const wireable = wireableEntryForDef(entry.capName, def);
2288
+ if (wireable) caps.push(wireable);
2289
+ }
2290
+ if (input.includeSynthesizable === true) {
2291
+ const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
2292
+ if (deviceType !== void 0) for (const def of require_dist.ALL_CAPABILITY_DEFINITIONS) {
2293
+ if (seen.has(def.name)) continue;
2294
+ if (def.scope !== "device" || def.kind === "wrapper") continue;
2295
+ if (def.status?.empty === void 0) continue;
2296
+ if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
2297
+ seen.add(def.name);
2298
+ const wireable = wireableEntryForDef(def.name, def);
2299
+ if (wireable) caps.push(wireable);
2300
+ }
2301
+ }
2302
+ return { caps };
2402
2303
  }
2403
2304
  //#endregion
2305
+ //#region src/builtins/device-manager/device-event-propagator.ts
2306
+ /**
2307
+ * Walks the parent chain for every device-sourced event and re-emits a
2308
+ * copy on each ancestor scope with `via[]` populated.
2309
+ *
2310
+ * Design goals:
2311
+ * - Transparent: drivers emit once on their own device scope; the
2312
+ * framework handles fan-out. Zero provider boilerplate.
2313
+ * - Anti-loop: events that already carry `via[]` are skipped (we only
2314
+ * propagate ORIGINAL emissions).
2315
+ * - Anti-cycle: the parent chain is bounded — if the device registry
2316
+ * is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
2317
+ * and logs a warning.
2318
+ * - Lazy: parent chain is resolved on-demand per event (no cached
2319
+ * topology). The lookup is O(depth) which is ≤2 in practice.
2320
+ *
2321
+ * `via` contract (from SystemEvent.via JSDoc):
2322
+ * - `via[0]` is the originating source (the device that produced the
2323
+ * event). Subsequent entries walk up the parent chain.
2324
+ * - On the re-emission, `source` is the ancestor at that level and
2325
+ * `via[0..i]` is the prefix of the chain up to and including the
2326
+ * first N ancestors below the current one.
2327
+ *
2328
+ * Example (grandchild → parent → grandparent):
2329
+ * Original: { source: {id: 7}, data: {...}, via: undefined }
2330
+ * Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
2331
+ * Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
2332
+ *
2333
+ * A consumer listening at `source.id === 1` receives re-emit 2 (with
2334
+ * `via` showing the chain). A consumer listening at `source.id === 7`
2335
+ * with `via === undefined` receives the original only.
2336
+ */
2337
+ /** Bounded walk — paranoia against corrupt device registries with cycles. */
2338
+ var MAX_CHAIN_DEPTH = 16;
2339
+ var DeviceEventPropagator = class {
2340
+ opts;
2341
+ unsubscribe = null;
2342
+ constructor(opts) {
2343
+ this.opts = opts;
2344
+ }
2345
+ start() {
2346
+ if (this.unsubscribe) return;
2347
+ const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
2348
+ this.unsubscribe = unsub;
2349
+ }
2350
+ stop() {
2351
+ if (!this.unsubscribe) return;
2352
+ this.unsubscribe();
2353
+ this.unsubscribe = null;
2354
+ }
2355
+ /** Exposed for tests — lets them inject events without the full bus. */
2356
+ handle(ev) {
2357
+ if (ev.via !== void 0) return;
2358
+ if (ev.source.type !== "device") return;
2359
+ const rawId = ev.source.id;
2360
+ const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
2361
+ if (!Number.isFinite(deviceId)) return;
2362
+ const chain = this.resolveParentChain(deviceId);
2363
+ if (chain.length === 0) return;
2364
+ const via = [ev.source];
2365
+ for (const ancestorId of chain) {
2366
+ const reEmission = {
2367
+ ...ev,
2368
+ source: {
2369
+ type: "device",
2370
+ id: ancestorId
2371
+ },
2372
+ via: [...via]
2373
+ };
2374
+ this.opts.eventBus.emit(reEmission);
2375
+ via.push({
2376
+ type: "device",
2377
+ id: ancestorId
2378
+ });
2379
+ }
2380
+ }
2381
+ resolveParentChain(deviceId) {
2382
+ const chain = [];
2383
+ const seen = new Set([deviceId]);
2384
+ let current = this.opts.getParentOf(deviceId);
2385
+ while (current != null) {
2386
+ if (seen.has(current)) {
2387
+ this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
2388
+ tags: { deviceId },
2389
+ meta: {
2390
+ cycleAt: current,
2391
+ chainSoFar: [...chain]
2392
+ }
2393
+ });
2394
+ return chain;
2395
+ }
2396
+ seen.add(current);
2397
+ chain.push(current);
2398
+ if (chain.length >= MAX_CHAIN_DEPTH) {
2399
+ this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
2400
+ tags: { deviceId },
2401
+ meta: {
2402
+ depth: chain.length,
2403
+ max: MAX_CHAIN_DEPTH
2404
+ }
2405
+ });
2406
+ break;
2407
+ }
2408
+ current = this.opts.getParentOf(current);
2409
+ }
2410
+ return chain;
2411
+ }
2412
+ };
2413
+ //#endregion
2404
2414
  //#region src/builtins/device-manager/device-link-cycle.ts
2405
2415
  function nodeKey(deviceId, cap) {
2406
2416
  return `${deviceId}:${cap}`;