@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.
- package/dist/addon-runner.js +2 -2
- package/dist/addon-runner.mjs +2 -2
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/device-manager/device-linked-devices.d.ts +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1595 -1585
- package/dist/builtins/device-manager/device-manager.addon.mjs +1595 -1585
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +11 -3
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +11 -3
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-BduBhv6N.js → dist-BNro5CRu.js} +92 -16
- package/dist/{dist-B_PSvr-m.mjs → dist-D7_69kDt.mjs} +92 -16
- package/dist/index.js +22 -5
- package/dist/index.mjs +22 -6
- package/dist/kernel/index.d.ts +1 -1
- package/dist/kernel/moleculer/readiness-context.d.ts +12 -8
- package/dist/kernel/moleculer/readiness-service.d.ts +16 -1
- package/dist/{manifest-python-deps-CPAMH5gV.js → manifest-python-deps-DszE7bYn.js} +35 -9
- package/dist/{manifest-python-deps-CIyvuHAB.mjs → manifest-python-deps-EM2H-jjS.mjs} +35 -9
- 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-
|
|
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-
|
|
673
|
+
//#region src/builtins/device-manager/device-queries.ts
|
|
674
674
|
/**
|
|
675
|
-
*
|
|
676
|
-
* device-manager addon
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
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
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
* `
|
|
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
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
await
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
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
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1020
|
+
return { removed };
|
|
1044
1021
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
1190
|
-
id
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1172
|
+
...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
|
|
1173
|
+
camDeviceId,
|
|
1174
|
+
resetToSource
|
|
1175
|
+
}),
|
|
1176
|
+
removedChildren
|
|
1205
1177
|
};
|
|
1206
1178
|
}
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1239
|
-
const
|
|
1240
|
-
const
|
|
1241
|
-
const
|
|
1242
|
-
if (
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
|
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
|
-
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
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
|
-
*
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
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
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
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
|
-
*
|
|
1439
|
-
*
|
|
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
|
|
1443
|
-
const
|
|
1444
|
-
|
|
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
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
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
|
-
*
|
|
1456
|
-
*
|
|
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
|
-
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1468
|
+
seenCaps.add(capName);
|
|
1504
1469
|
}
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
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 (
|
|
1516
|
-
|
|
1517
|
-
deviceId
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1585
|
-
|
|
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
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
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
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
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
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
const
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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-
|
|
1575
|
+
//#region src/builtins/device-manager/device-aggregation.ts
|
|
1637
1576
|
/**
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
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
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
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
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
if (!
|
|
1693
|
-
const
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
id: ancestorId
|
|
1624
|
+
meta: {
|
|
1625
|
+
capName: entry.capName,
|
|
1626
|
+
method,
|
|
1627
|
+
error: msg
|
|
1628
|
+
}
|
|
1709
1629
|
});
|
|
1630
|
+
return null;
|
|
1710
1631
|
}
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
|
|
1734
|
-
|
|
1692
|
+
capName: info.name,
|
|
1693
|
+
method,
|
|
1694
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1735
1695
|
}
|
|
1736
1696
|
});
|
|
1737
|
-
|
|
1697
|
+
return null;
|
|
1738
1698
|
}
|
|
1739
|
-
|
|
1740
|
-
}
|
|
1741
|
-
return chain;
|
|
1699
|
+
})());
|
|
1742
1700
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
//#region src/builtins/device-manager/device-queries.ts
|
|
1701
|
+
return (await Promise.all(tasks)).filter((r) => r !== null);
|
|
1702
|
+
}
|
|
1746
1703
|
/**
|
|
1747
|
-
*
|
|
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
|
-
*
|
|
1755
|
-
*
|
|
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
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1765
|
-
* `
|
|
1766
|
-
*
|
|
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
|
|
1769
|
-
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
-
|
|
1868
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
if (
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
-
|
|
2054
|
-
tags: {
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2076
|
-
*
|
|
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
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
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
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
return
|
|
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
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
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
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
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
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
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
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
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
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
resetToSource
|
|
2247
|
-
}),
|
|
2248
|
-
removedChildren
|
|
2146
|
+
success: true,
|
|
2147
|
+
failures
|
|
2249
2148
|
};
|
|
2250
2149
|
}
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
if (
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
-
|
|
2275
|
-
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
*
|
|
2344
|
-
*
|
|
2345
|
-
* `
|
|
2346
|
-
*
|
|
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
|
-
|
|
2349
|
-
const
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
const
|
|
2353
|
-
const
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
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
|
|
2392
|
-
const
|
|
2393
|
-
|
|
2394
|
-
if (
|
|
2395
|
-
await
|
|
2396
|
-
|
|
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}`;
|