feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing

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
This commit is contained in:
Joseph Doherty
2026-07-16 10:56:31 -04:00
parent 8b0b627f81
commit 3efcf8014b
30 changed files with 1186 additions and 746 deletions
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
return EquipmentReferenceMap.Build(references, tagsById, resolver);
}
/// <summary>
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
BuildRawAndUnsSubtrees(JsonElement root)
{
var rawFolders = new List<RawFolder>();
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
rawFolders.Add(new RawFolder
{
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var driverInstances = new List<DriverInstance>();
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
driverInstances.Add(new DriverInstance
{
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var devices = new List<Device>();
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var di = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
devices.Add(new Device
{
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
});
}
var tagGroups = new List<TagGroup>();
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
tagGroups.Add(new TagGroup
{
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
});
}
var tags = new List<Tag>();
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() ?? "{}" : "{}";
tags.Add(new Tag
{
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
AccessLevel = accessLevel, TagConfig = tagConfig,
});
}
var unsTagReferences = new List<UnsTagReference>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var id = ReadString(el, "UnsTagReferenceId");
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
unsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
});
}
var unsAreas = new List<UnsArea>();
foreach (var el in EnumerateArray(root, "UnsAreas"))
{
var id = ReadString(el, "UnsAreaId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
}
var unsLines = new List<UnsLine>();
foreach (var el in EnumerateArray(root, "UnsLines"))
{
var id = ReadString(el, "UnsLineId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
}
var equipment = new List<Equipment>();
foreach (var el in EnumerateArray(root, "Equipment"))
{
var id = ReadString(el, "EquipmentId");
if (string.IsNullOrWhiteSpace(id)) continue;
equipment.Add(new Equipment
{
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
Name = ReadString(el, "Name") ?? id!,
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
MachineCode = ReadString(el, "MachineCode") ?? id!,
});
}
var composition = AddressSpaceComposer.Compose(
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
unsTagReferences: unsTagReferences, tags: tags,
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
}
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
};
}
catch (JsonException)
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
// raw VALUE + UNS reference node is correctly cluster-scoped.
RawContainers = full.RawContainers,
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
};
}