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