3efcf8014b
Wave B of Batch 4 — the runtime binding seam for the dual namespace. Applier (both realms, explicit realm at every sink call site): - MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath), all in AddressSpaceRealm.Raw; historian tagname = override else RawPath. - MaterialiseUnsReferences: each UNS reference Variable under its equipment folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/ historian tagname from the backing raw tag (both NodeIds -> one tagname). - FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS refs in place (raw-container removal falls back to rebuild). DriverHostActor (dual-NodeId, single-source fan-out): - _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp. Write inverse map keyed by the bare id; the ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a write to either NodeId resolves the same driver ref (-> RawPath write). - Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate + AlarmStateUpdate carry the realm through to the sink. Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now). DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity with the composer (reconstruct entities -> AddressSpaceComposer.Compose). Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink / DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now explicit (realm reordered before the trailing optionals on EnsureVariable + MaterialiseAlarmCondition). Node-manager convenience methods keep their defaults (they are not the interface impl; Sdk delegates explicitly). Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) + DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3 raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
119 lines
6.6 KiB
C#
119 lines
6.6 KiB
C#
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
/// <summary>The mapped result of grafting discovered nodes under an equipment node.</summary>
|
|
/// <param name="Folders">
|
|
/// Folders to ensure, in insertion order (parent-before-child within each node's prefix chain) — NOT
|
|
/// globally depth-sorted. The applier sorts by depth before ensuring, so consumers must not assume a
|
|
/// global parent-before-child ordering across the whole list.
|
|
/// </param>
|
|
/// <param name="Variables">Variables to ensure under the (post-collapse) folders.</param>
|
|
/// <param name="RoutingByRef">Driver FullReference -> equipment NodeId, for live-value routing.</param>
|
|
public sealed record DiscoveredInjectionPlan(
|
|
IReadOnlyList<DiscoveredFolder> Folders,
|
|
IReadOnlyList<DiscoveredVariable> Variables,
|
|
IReadOnlyDictionary<string, string> RoutingByRef); // driver FullReference -> equipment NodeId
|
|
|
|
/// <summary>
|
|
/// Pure mapper: re-roots a driver's captured discovery tree under an equipment node, deduping
|
|
/// authored Config-DB refs and collapsing the single device-host folder. See the design doc
|
|
/// 2026-06-26-otopcua-fixedtree-equipment-injection-design.md.
|
|
/// </summary>
|
|
public static class DiscoveredNodeMapper
|
|
{
|
|
/// <summary>
|
|
/// Maps captured <paramref name="nodes"/> into folders + variables (NodeIds scoped under
|
|
/// <paramref name="equipmentId"/>) plus a driver-FullReference → equipment-NodeId routing map.
|
|
/// </summary>
|
|
/// <param name="equipmentId">The owning equipment's NodeId (root of the grafted subtree).</param>
|
|
/// <param name="nodes">The captured discovery tree (from <c>CapturingAddressSpaceBuilder</c>).</param>
|
|
/// <param name="authoredRefs">
|
|
/// Driver FullReferences already authored as Config-DB equipment tags for this driver —
|
|
/// skipped so a discovered node never shadows an authored one.
|
|
/// </param>
|
|
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
|
|
public static DiscoveredInjectionPlan Map(
|
|
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
|
|
{
|
|
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
|
|
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
|
|
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
|
|
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
|
|
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
|
|
|
|
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
|
|
// device-host folder under the driver root, e.g. "10.0.0.5:8193"), drop it so the path reads
|
|
// FOCAS/Identity/... rather than FOCAS/10.0.0.5:8193/Identity/.... With >=2 distinct devices the
|
|
// level is retained so identical leaf names across devices don't collide (degrades gracefully).
|
|
var collapseIndex1 = kept.Count > 0
|
|
&& kept.All(n => n.FolderPathSegments.Count >= 2)
|
|
&& kept.Select(n => n.FolderPathSegments[1]).Distinct(StringComparer.Ordinal).Count() == 1;
|
|
|
|
static IReadOnlyList<string> Effective(IReadOnlyList<string> segs, bool collapse)
|
|
=> collapse ? [segs[0], .. segs.Skip(2)] : segs;
|
|
|
|
var folders = new Dictionary<string, DiscoveredFolder>(StringComparer.Ordinal);
|
|
var variables = new List<DiscoveredVariable>();
|
|
var routing = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
foreach (var n in kept)
|
|
{
|
|
var segs = Effective(n.FolderPathSegments, collapseIndex1);
|
|
|
|
// Ensure every prefix folder, deduped, each parented at its prefix (the first segment's
|
|
// parent is the equipment itself).
|
|
for (var i = 0; i < segs.Count; i++)
|
|
{
|
|
var folderPath = string.Join('/', segs.Take(i + 1));
|
|
var nodeId = Combine(rootNodeId, folderPath);
|
|
if (folders.ContainsKey(nodeId)) continue;
|
|
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
|
|
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
|
|
}
|
|
|
|
var varFolderPath = string.Join('/', segs);
|
|
var varNodeId = string.IsNullOrEmpty(varFolderPath)
|
|
? Combine(rootNodeId, n.BrowseName)
|
|
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
|
|
// A folder-less variable parents directly at the root device node.
|
|
var varParent = string.IsNullOrEmpty(varFolderPath)
|
|
? rootNodeId
|
|
: Combine(rootNodeId, varFolderPath);
|
|
variables.Add(new DiscoveredVariable(
|
|
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
|
|
routing[n.FullReference] = varNodeId;
|
|
}
|
|
|
|
return new DiscoveredInjectionPlan(folders.Values.ToList(), variables, routing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a <see cref="DriverDataType"/> to the OPC-UA-built-in type STRING that
|
|
/// <c>OtOpcUaNodeManager.EnsureVariable</c>'s <c>ResolveBuiltInDataType</c> accepts — so a
|
|
/// discovered variable resolves to the same built-in type as an authored equipment tag. Most
|
|
/// enum names pass through verbatim; <see cref="DriverDataType.Float32"/>/<see cref="DriverDataType.Float64"/>
|
|
/// map to the SDK's "Float"/"Double" names, and <see cref="DriverDataType.Reference"/> (a Galaxy
|
|
/// attribute reference) is carried as an OPC UA String per the enum's own contract.
|
|
/// </summary>
|
|
private static string ToBuiltinTypeString(DriverDataType dt) => dt switch
|
|
{
|
|
DriverDataType.Boolean => "Boolean",
|
|
DriverDataType.Int16 => "Int16",
|
|
DriverDataType.Int32 => "Int32",
|
|
DriverDataType.Int64 => "Int64",
|
|
DriverDataType.UInt16 => "UInt16",
|
|
DriverDataType.UInt32 => "UInt32",
|
|
DriverDataType.UInt64 => "UInt64",
|
|
DriverDataType.Float32 => "Float",
|
|
DriverDataType.Float64 => "Double",
|
|
DriverDataType.String => "String",
|
|
DriverDataType.DateTime => "DateTime",
|
|
DriverDataType.Reference => "String",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(dt), dt, "Unmapped DriverDataType."),
|
|
};
|
|
}
|