v3 batch1 Wave-B contracts: byName-only resolver + RawTagEntry + green Core

- EquipmentTagRefResolver<TDef> re-keyed to RawPath: byName(RawPath)-only lookup,
  blob-parse fallback deleted (a miss is a miss). Clear() retained as documented no-op.
- New RawTagEntry(RawPath, TagConfig, WriteIdempotent) in Core.Abstractions — the
  artifact->driver tag-delivery unit both WP5 (factory consumer) and WP4 (artifact
  producer) code against.
- EquipmentNodeWalker greened for the dark Batch-1 address space: drop the raw-tag
  emission path (relied on removed Tag.EquipmentId/FullName); keep Area/Line/Equipment
  folders + VirtualTags + ScriptedAlarms. Raw-tag reference materialization is Batch 4.

Commons, Core.Abstractions, Configuration, Core all build green.
This commit is contained in:
Joseph Doherty
2026-07-15 19:26:55 -04:00
parent bda27e2aab
commit 906800abc0
2 changed files with 66 additions and 175 deletions
@@ -1,53 +1,60 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Resolves a driver subscription/read/write <c>fullReference</c> to a driver tag-definition,
/// bridging the two authoring models: a legacy authored tag-table entry (looked up by name)
/// OR an equipment tag whose reference is its raw <c>TagConfig</c> JSON (parsed on first use
/// and cached). Negative results are cached too, so a genuinely-unknown reference is parsed once.
/// v3: resolves a driver subscription/read/write reference — which is now always a
/// <c>RawPath</c> — to the driver's internal tag-definition. The lookup is a single authored-table
/// hit: each deploy hands the driver its authored raw tags keyed by RawPath (see
/// <see cref="RawTagEntry"/>), the driver builds a <c>RawPath → TDef</c> table from them, and this
/// resolver wraps that table's lookup. The pre-v3 blob-parse fallback (an equipment tag whose
/// reference WAS its raw <c>TagConfig</c> JSON) is retired: a miss is a miss — <see cref="TryResolve"/>
/// returns <see langword="false"/> and the driver surfaces Bad quality, never a parse attempt.
/// </summary>
/// <typeparam name="TDef">The driver's internal tag-definition type.</typeparam>
public sealed class EquipmentTagRefResolver<TDef> where TDef : class
{
private readonly Func<string, TDef?> _byName;
private readonly Func<string, TDef?> _parseRef;
private readonly ConcurrentDictionary<string, TDef?> _cache = new(StringComparer.Ordinal);
private readonly Func<string, TDef?> _byRawPath;
/// <summary>Initializes a new instance of the <see cref="EquipmentTagRefResolver{TDef}"/> class.</summary>
/// <param name="byName">Authored tag-table lookup (returns null on miss).</param>
/// <param name="parseRef">Parses an equipment-tag reference (TagConfig JSON) into a transient def, or null.</param>
public EquipmentTagRefResolver(Func<string, TDef?> byName, Func<string, TDef?> parseRef)
/// <param name="byRawPath">The driver's authored-table lookup (RawPath → definition; null on miss).</param>
public EquipmentTagRefResolver(Func<string, TDef?> byRawPath)
{
ArgumentNullException.ThrowIfNull(byName);
ArgumentNullException.ThrowIfNull(parseRef);
_byName = byName;
_parseRef = parseRef;
ArgumentNullException.ThrowIfNull(byRawPath);
_byRawPath = byRawPath;
}
/// <summary>True when <paramref name="fullReference"/> resolves to a def (authored or equipment).</summary>
/// <param name="fullReference">The wire reference handed to the driver.</param>
/// <summary>True when <paramref name="rawPath"/> resolves to an authored tag-definition.</summary>
/// <param name="rawPath">The RawPath wire reference handed to the driver.</param>
/// <param name="def">
/// The resolved tag-definition when this returns <see langword="true"/>. When this returns
/// <see langword="false"/> the value is undefined — callers must not use it.
/// <c>[MaybeNullWhen(false)]</c> informs the nullable-reference-types (NRT) analyser so
/// callers in NRT-enabled contexts do not need to suppress warnings.
/// </param>
/// <returns><see langword="true"/> when a definition was found.</returns>
public bool TryResolve(string fullReference, [MaybeNullWhen(false)] out TDef def)
public bool TryResolve(string rawPath, [MaybeNullWhen(false)] out TDef def)
{
var authored = _byName(fullReference);
if (authored is not null) { def = authored; return true; }
var resolved = _cache.GetOrAdd(fullReference, _parseRef);
if (resolved is not null) { def = resolved; return true; }
def = default;
return false;
def = _byRawPath(rawPath);
return def is not null;
}
/// <summary>Drops the transient-parse cache (call on driver reinitialise so a config change re-parses).</summary>
public void Clear() => _cache.Clear();
/// <summary>
/// No-op retained for call-site compatibility. v3 holds no transient parse cache — the driver
/// rebuilds its authored <c>RawPath → TDef</c> table on reinitialise, and this resolver reads
/// the driver's live table by closure, so there is nothing to clear here.
/// </summary>
public void Clear()
{
}
}
/// <summary>
/// The unit of tag delivery from the deploy artifact to a driver: one authored raw
/// <c>Tag</c>, identified by its <see cref="RawPath"/> (the v3 identity), carrying the
/// driver-specific address blob (<see cref="TagConfig"/>) the driver dials and the platform flags
/// the driver needs. A driver maps each entry to its typed definition (keyed by RawPath) via its
/// own <c>TagConfig → definition</c> factory at table-build time.
/// </summary>
/// <param name="RawPath">The tag's cluster-scoped RawPath — the driver wire reference / identity.</param>
/// <param name="TagConfig">The tag's schemaless driver-specific <c>TagConfig</c> JSON (the dialled address).</param>
/// <param name="WriteIdempotent">Whether writes to this tag are safe to replay (R2 resilience contract).</param>
public sealed record RawTagEntry(string RawPath, string TagConfig, bool WriteIdempotent);
@@ -1,80 +1,37 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
/// <summary>
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
/// address-space build per <see cref="IDriver"/> whose
/// <c>Namespace.Kind = Equipment</c>; non-Equipment namespaces are
/// exempt and reach this walker only indirectly through
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
/// Materializes the canonical Unified Namespace browse tree (Area → Line → Equipment) for a
/// cluster from its <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> rows, plus the
/// per-equipment VirtualTag + ScriptedAlarm nodes.
/// <para>
/// <b>v3:</b> equipment no longer binds a driver or authors tags. Raw tags are surfaced into
/// equipment by <c>UnsTagReference</c> and materialized as the UNS-namespace fan-out of their
/// backing raw nodes — that projection lands with the dual-namespace address space (Batch 4),
/// NOT here. This walker therefore emits the equipment hierarchy + VirtualTags + ScriptedAlarms
/// only; raw-tag reference variables are intentionally absent (the address space is deliberately
/// dark until Batch 4). It remains a pure, SDK-free helper retained for unit-test support; real
/// server deployments compose through the <c>AddressSpaceComposer → Applier → Sink → NodeManager</c>
/// chain.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// <b>Composition strategy.</b> Accepted Option A — Config
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
/// snapshot as the authoritative published surface. Every Equipment row becomes a
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
/// it. Driver-discovered tags that have no Config-DB row are not added by this
/// walker — the ITagDiscovery path continues to exist for FolderPath-scoped tags +
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
/// </para>
///
/// <para>
/// <b>Under each Equipment node.</b> Five identifier properties
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
/// resolve equipment by whichever identifier they natively use without a sidecar.
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
/// Identification sub-folder with the nine fields when at least one
/// is non-null; when all nine are null the sub-folder is omitted rather than
/// appearing empty.
/// </para>
///
/// <para>
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
/// runtime the dispatch layer routes Read/Write calls through the configured
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
/// UI + OPC UA client inspection of node status.
/// </para>
///
/// <para>
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
/// </para>
/// </remarks>
public static class EquipmentNodeWalker
{
/// <summary>
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
/// Area → Line → Equipment folders under it, then identifier properties + the
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>: Area → Line →
/// Equipment folders, identifier properties + the OPC 40010 Identification sub-folder per
/// equipment, and the equipment's VirtualTag + ScriptedAlarm variable nodes.
/// </summary>
/// <param name="namespaceBuilder">
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
/// </param>
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
/// <param name="namespaceBuilder">The builder scoped to the UNS namespace root.</param>
/// <param name="content">Pre-loaded + pre-filtered rows for a single cluster.</param>
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
{
ArgumentNullException.ThrowIfNull(namespaceBuilder);
ArgumentNullException.ThrowIfNull(content);
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
// O(N·M) re-scan at each UNS level on large fleets.
var linesByArea = content.Lines
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
@@ -83,11 +40,6 @@ public static class EquipmentNodeWalker
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var tagsByEquipment = content.Tags
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
var virtualTagsByEquipment = (content.VirtualTags ?? [])
.Where(v => v.Enabled)
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
@@ -114,10 +66,6 @@ public static class EquipmentNodeWalker
AddIdentifierProperties(equipmentBuilder, equipment);
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
foreach (var tag in equipmentTags)
AddTagVariable(equipmentBuilder, tag);
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
foreach (var vtag in vTags)
AddVirtualTagVariable(equipmentBuilder, vtag);
@@ -131,11 +79,9 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Adds the five operator-facing identifiers as OPC UA properties
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
/// the data model so they're skipped when null to avoid empty-string noise in the
/// browse tree.
/// Adds the operator-facing identifiers as OPC UA properties on the Equipment node.
/// EquipmentId + EquipmentUuid + MachineCode are always populated; ZTag + SAPID are skipped
/// when null to avoid empty-string noise in the browse tree.
/// </summary>
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
{
@@ -149,67 +95,13 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
/// Unreachable-address behavior: the variable is created; the
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
/// </summary>
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
{
// SecurityClass and IsHistorized are intentionally not extracted from TagConfig here.
// In production, EquipmentNodeWalker.Walk is superseded by the
// AddressSpaceComposer → AddressSpaceApplier → Sink → NodeManager chain, which reads
// both fields from TagConfig JSON directly (via DeploymentArtifact.ExtractTagHistorize
// and the SecurityClassification column). This walker is retained for unit-test support
// only; real server deployments never invoke Walk to build live nodes.
var attr = new DriverAttributeInfo(
FullName: ExtractFullName(tag.TagConfig),
DriverDataType: ParseDriverDataType(tag.DataType),
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.FreeAccess,
IsHistorized: false);
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
}
/// <summary>
/// Cross-driver TagConfig convention — the Config DB's <c>CK_Tag_TagConfig_IsJson</c>
/// check constraint requires TagConfig to be a JSON object, and every shipped driver
/// (Galaxy / Modbus / AB CIP / S7 / FOCAS / TwinCAT / ABLegacy) stores the wire-level
/// address in a top-level <c>FullName</c> field. Extracting it here keeps the walker
/// driver-agnostic while giving the driver the plain address string its backend
/// expects at read-time — the raw JSON would otherwise be passed verbatim to
/// <c>IReadable.ReadAsync</c> and the driver would fail to resolve the tag.
/// </summary>
/// <remarks>
/// Falls back to the raw <paramref name="tagConfig"/> if it doesn't parse as JSON or
/// the <c>FullName</c> field is absent. This preserves the pre-refactor behaviour for
/// any legacy row that slipped past the check constraint or any future driver that
/// wants an opaque non-JSON reference.
/// </remarks>
/// <param name="tagConfig">The tag configuration JSON or string.</param>
/// <returns>The extracted <c>FullName</c> value, or the raw <paramref name="tagConfig"/> if it can't be extracted.</returns>
internal static string ExtractFullName(string tagConfig) =>
TagConfigIntent.Parse(tagConfig).FullName;
/// <summary>
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
/// name string) into the enum value. Unknown names fall back to
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
/// abort the whole walk; the underlying driver still sees the original TagConfig
/// address + can surface its own typed value via the OPC UA variant at read time.
/// Parse a data-type name (stored as the <see cref="DriverDataType"/> enum name) into the
/// enum value; unknown names fall back to <see cref="DriverDataType.String"/>.
/// </summary>
private static DriverDataType ParseDriverDataType(string raw) =>
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
/// <summary>
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
/// driver.
/// </summary>
/// <summary>Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/> variable node.</summary>
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
{
var attr = new DriverAttributeInfo(
@@ -227,14 +119,7 @@ public static class EquipmentNodeWalker
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
}
/// <summary>
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
/// materialization path.
/// </summary>
/// <summary>Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/> variable node.</summary>
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
{
var attr = new DriverAttributeInfo(
@@ -254,15 +139,14 @@ public static class EquipmentNodeWalker
}
/// <summary>
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
/// DB rows. All four collections are scoped to the same
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
/// was applied by the caller + does no cross-namespace validation.
/// Pre-loaded + pre-filtered snapshot of one cluster's UNS content (Area / Line / Equipment +
/// per-equipment VirtualTags / ScriptedAlarms). v3: raw-tag references (<c>UnsTagReference</c>)
/// are materialized by the Batch-4 dual-namespace fan-out, not by this walker, so they are not
/// part of this snapshot.
/// </summary>
public sealed record EquipmentNamespaceContent(
IReadOnlyList<UnsArea> Areas,
IReadOnlyList<UnsLine> Lines,
IReadOnlyList<Equipment> Equipment,
IReadOnlyList<Tag> Tags,
IReadOnlyList<VirtualTag>? VirtualTags = null,
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);