v3(b1-abcip): RawPath read-path for AbCip (multi-device)

Apply the reviewed Modbus exemplar to the AbCip multi-device driver:
- Mapper AbCipEquipmentTagParser -> AbCipTagDefinitionFactory; TryParse -> FromTagConfig(tagConfig, rawPath, out def); drop leading-{ heuristic + the deviceHostAddress key; add inverse ToTagConfig; round-trip arrays/members/safety for coverage.
- Options: Tags (typed) -> RawTags (RawTagEntry list); Devices list kept.
- Driver: byName-only resolver over _tagsByRawPath (Ordinal); table built from RawTags via FromTagConfig, threading entry.DeviceName (device routing key) + entry.WriteIdempotent onto the def. Multi-device routing keys on DeviceName-or-host via a _devicesByRoutingKey index; discovery groups _declaredTags by RoutesToDevice.
- Factory: retire the pre-declared Tags DTO/BuildTag; bind List<RawTagEntry> RawTags.
- Probe: derive probe tag path from RawTags via the mapper.
- Cli BuildOptions projects typed tags -> RawTagEntry (ToTagConfig).
- Tests: AbCipRawTags helper (typed def -> RawTagEntry); parser tests -> FromTagConfig; blob-fallback driver tests rewritten to author via RawTags + read by RawPath. Driver.AbCip.Tests 342/342, Cli.Tests 42/42.

TODO(v3 WaveC): DeviceName becomes a pure logical name once the device connection host moves to the Device row's DeviceConfig.
This commit is contained in:
Joseph Doherty
2026-07-15 20:18:28 -04:00
parent c379e246d0
commit c0379742bc
32 changed files with 819 additions and 783 deletions
@@ -1,10 +1,12 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// AB CIP / EtherNet-IP driver configuration, bound from the driver's <c>DriverConfig</c>
/// JSON at <c>DriverHost.RegisterAsync</c>. One instance supports N devices (PLCs) behind
/// the same driver; per-device routing is keyed on <see cref="AbCipDeviceOptions.HostAddress"/>
/// via <c>IPerCallHostResolver</c>.
/// the same driver; per-device routing is keyed on the tag's device routing key (the RawPath's
/// device segment, delivered on <see cref="RawTagEntry.DeviceName"/>).
/// </summary>
public sealed class AbCipDriverOptions
{
@@ -12,14 +14,23 @@ public sealed class AbCipDriverOptions
/// PLCs this driver instance talks to. Each device contributes its own <see cref="AbCipHostAddress"/>
/// string as the <c>hostName</c> key used by resilience pipelines and the Admin UI.
/// </summary>
/// <remarks>
/// Wave C moves the device's live connection host into the <c>Device</c> row's <c>DeviceConfig</c>;
/// for Wave B the list stays bound from <c>DriverConfig</c> as today, and a tag routes to its device
/// by matching <see cref="RawTagEntry.DeviceName"/> against the device's name (<see cref="AbCipDeviceOptions.DeviceName"/>)
/// or its <see cref="AbCipDeviceOptions.HostAddress"/>.
/// </remarks>
public IReadOnlyList<AbCipDeviceOptions> Devices { get; init; } = [];
/// <summary>
/// Pre-declared tag map across all devices. Pre-declared tags always emit during
/// discovery; opt in to controller-side discovery via
/// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag + DeviceName routing key); the driver maps each through
/// <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
/// Pre-declared tags always emit during discovery; opt in to controller-side discovery via
/// <see cref="EnableControllerBrowse"/>.
/// </summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -111,8 +122,13 @@ public sealed record AbCipDeviceOptions(
/// <summary>
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
/// </summary>
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference.</param>
/// <param name="DeviceHostAddress">Which device (<see cref="AbCipDeviceOptions.HostAddress"/>) this tag lives on.</param>
/// <param name="Name">Tag name; becomes the OPC UA browse name and full reference (the RawPath in v3).</param>
/// <param name="DeviceHostAddress">The tag's <b>device routing key</b> — the RawPath's device segment,
/// threaded in from <see cref="RawTagEntry.DeviceName"/> by the driver at table-build time (the mapper
/// leaves it empty; device placement is no longer in the TagConfig blob). Resolved against the driver's
/// devices by matching <see cref="AbCipDeviceOptions.DeviceName"/> or <see cref="AbCipDeviceOptions.HostAddress"/>.
/// TODO(v3 WaveC): once the connection host moves to the Device row's DeviceConfig this is a pure logical
/// device name; the name still reads <c>DeviceHostAddress</c> to avoid churn across the driver + CLI.</param>
/// <param name="TagPath">Logix symbolic path (controller or program scope).</param>
/// <param name="DataType">Logix atomic type, or <see cref="AbCipDataType.Structure"/> for UDT-typed tags.</param>
/// <param name="Writable">When <c>true</c> and the tag's ExternalAccess permits writes, IWritable routes writes here.</param>
@@ -1,123 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>AbCipTagConfigModel</c>) into a transient <see cref="AbCipTagDefinition"/> whose
/// <see cref="AbCipTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class AbCipEquipmentTagParser
{
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
/// <remarks>
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
/// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
/// </remarks>
public static bool TryParse(string reference, out AbCipTagDefinition def)
{
def = null!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|| tagPathEl.ValueKind != JsonValueKind.String)
return false;
var tagPath = tagPathEl.GetString();
if (string.IsNullOrWhiteSpace(tagPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false;
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
// foundation materialises a [1] OPC UA array node — so it must read as an array, not a
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
// have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
// Now via the shared TagConfigJson.ReadWritable (explicit-false-only), byte-identical to the
// hand-rolled read it replaces.
var writable = TagConfigJson.ReadWritable(root);
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
/// <c>arrayLength</c> is a number <c>&gt;= 1</c>. Any other combination (isArray absent/false,
/// or isArray:true with arrayLength missing/invalid/&lt;1) is a SCALAR and returns
/// <c>(IsArray: false, ElementCount: 1)</c>. This matches every other driver (Modbus, S7,
/// TwinCAT, AbLegacy) which also return scalar for that degenerate input.
/// </summary>
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
{
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
if (!isArray) return (false, 1);
if (o.TryGetProperty("arrayLength", out var len)
&& len.ValueKind == JsonValueKind.Number
&& len.TryGetInt32(out var n)
&& n >= 1)
return (true, n);
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
return (false, 1);
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>AbCipTagConfigModel</c>) into an <see cref="AbCipTagDefinition"/>. Under v3 a tag's
/// identity is its <b>RawPath</b> (a cluster-scoped slash path), not the address blob — so the
/// produced definition's <see cref="AbCipTagDefinition.Name"/> is the RawPath the driver was handed,
/// which is exactly the wire reference the driver's <c>RawPath → def</c> resolver keys on. The driver
/// builds that table by mapping each <see cref="RawTagEntry"/> the deploy artifact delivers through
/// <see cref="FromTagConfig"/>; <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the
/// Client CLI + the driver-test <c>AbCipRawTags</c> helper) that hold a typed definition and need to
/// synthesise the <c>TagConfig</c> blob for a <see cref="RawTagEntry"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Device placement is NOT in the blob.</b> The pre-v3 parser read a <c>deviceHostAddress</c>
/// key off the TagConfig; v3 drops it — a tag's device is the RawPath's device segment, delivered
/// out-of-band on <see cref="RawTagEntry.DeviceName"/> and threaded onto the definition's
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (the device routing key) by the driver at
/// table-build time. <see cref="FromTagConfig"/> therefore leaves that field empty.
/// </para>
/// <para>
/// <b>Array / member / safety round-trip.</b> Today's production authoring surface
/// (<c>AbCipTagConfigModel</c>) emits only <c>tagPath</c> / <c>dataType</c> / <c>writable</c>, so a
/// real equipment tag maps to a scalar atomic definition. The additional <c>isArray</c> /
/// <c>arrayLength</c> / <c>safetyTag</c> / <c>members</c> keys are read (and emitted by
/// <see cref="ToTagConfig"/>) so the retired pre-declared-tag coverage — arrays and declared UDT
/// member fan-out — survives the migration to the RawPath seam. They are additive: absent keys map
/// to the scalar / no-member / non-safety defaults.
/// </para>
/// </remarks>
public static class AbCipTagDefinitionFactory
{
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The mandatory addressing field is the Logix <c>tagPath</c>; <c>dataType</c> is read STRICTLY — a
/// present-but-invalid (typo'd) value rejects the whole tag (returns <see langword="false"/> ⇒ the
/// driver surfaces <c>BadNodeIdUnknown</c>) rather than silently defaulting to a wrong-width Good.
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> and <see cref="AbCipTagDefinition.WriteIdempotent"/>
/// are NOT read from the blob — the driver threads them in from the tag's <see cref="RawTagEntry"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid AbCip address object.</returns>
/// <remarks>
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c> boolean;
/// it defaults to <c>true</c> when absent, matching the record's documented default. Operators who
/// need a read-only OPC UA surface can author <c>"writable":false</c>; the PLC's ExternalAccess
/// attribute remains the effective write gate at the wire level.
/// </remarks>
public static bool FromTagConfig(string tagConfig, string rawPath, out AbCipTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
// AbCip is a symbolic driver: the mandatory addressing field is the Logix tag path.
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("tagPath", out var tagPathEl)
|| tagPathEl.ValueKind != JsonValueKind.String)
return false;
var tagPath = tagPathEl.GetString();
if (string.IsNullOrWhiteSpace(tagPath)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false;
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A 1-element
// array is a VALID 1-element array (the foundation materialises a [1] OPC UA array node).
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
var writable = TagConfigJson.ReadWritable(root);
var safetyTag = ReadBool(root, "safetyTag");
// Optional declared UDT member fan-out (round-trips the retired pre-declared coverage). A
// malformed member (missing name / typo'd dataType) rejects the whole tag, mirroring the
// strict top-level contract.
IReadOnlyList<AbCipStructureMember>? members = null;
if (root.TryGetProperty("members", out var membersEl) && membersEl.ValueKind == JsonValueKind.Array)
{
var list = new List<AbCipStructureMember>();
foreach (var m in membersEl.EnumerateArray())
{
if (m.ValueKind != JsonValueKind.Object
|| !m.TryGetProperty("name", out var nameEl)
|| nameEl.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(nameEl.GetString()))
return false;
if (!TagConfigJson.TryReadEnumStrict(m, "dataType", AbCipDataType.DInt, out var memberType)) return false;
var (memberIsArray, memberCount) = ReadArrayShape(m);
list.Add(new AbCipStructureMember(
Name: nameEl.GetString()!,
DataType: memberType,
Writable: ReadBoolDefaultTrue(m, "writable"),
WriteIdempotent: ReadBool(m, "writeIdempotent"),
ElementCount: memberCount,
IsArray: memberIsArray));
}
if (list.Count > 0) members = list;
}
def = new AbCipTagDefinition(
Name: rawPath, DeviceHostAddress: "", TagPath: tagPath!,
DataType: dataType, Writable: writable, WriteIdempotent: false,
Members: members, SafetyTag: safetyTag, ElementCount: elementCount, IsArray: isArray);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI, the driver-test
/// <c>AbCipRawTags</c> helper) that construct an <see cref="AbCipTagDefinition"/> and need the
/// <c>TagConfig</c> string to hand the driver as a <see cref="RawTagEntry"/>. Enum values are written
/// as their name strings; optional fields are emitted only when non-default so the blob stays minimal.
/// <see cref="AbCipTagDefinition.DeviceHostAddress"/> (device routing key) and
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> are NOT emitted — they travel on the
/// <see cref="RawTagEntry"/>, not inside the blob.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(AbCipTagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["tagPath"] = def.TagPath,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
if (def.IsArray)
{
o["isArray"] = true;
o["arrayLength"] = Math.Max(1, def.ElementCount);
}
if (def.SafetyTag) o["safetyTag"] = true;
if (def.Members is { Count: > 0 } members)
{
var arr = new JsonArray();
foreach (var m in members)
{
var mo = new JsonObject
{
["name"] = m.Name,
["dataType"] = m.DataType.ToString(),
["writable"] = m.Writable,
};
if (m.WriteIdempotent) mo["writeIdempotent"] = true;
if (m.IsArray)
{
mo["isArray"] = true;
mo["arrayLength"] = Math.Max(1, m.ElementCount);
}
arr.Add(mo);
}
o["members"] = arr;
}
return o.ToJsonString();
}
/// <summary>
/// Resolve the 1-D array shape from an <c>isArray</c> / <c>arrayLength</c> pair (the foundation
/// contract carrier). The canonical rule: the tag is an ARRAY ⟺ <c>isArray</c> is truthy AND
/// <c>arrayLength</c> is a number <c>&gt;= 1</c>. Any other combination is a SCALAR and returns
/// <c>(IsArray: false, ElementCount: 1)</c> — matching every other driver.
/// </summary>
private static (bool IsArray, int ElementCount) ReadArrayShape(JsonElement o)
{
var isArray = o.TryGetProperty("isArray", out var a) && a.ValueKind == JsonValueKind.True;
if (!isArray) return (false, 1);
if (o.TryGetProperty("arrayLength", out var len)
&& len.ValueKind == JsonValueKind.Number
&& len.TryGetInt32(out var n)
&& n >= 1)
return (true, n);
// isArray:true but arrayLength missing/invalid/<1 — canonical rule: scalar (matches all other drivers).
return (false, 1);
}
/// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("AbCip TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<AbCipDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("AbCip TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static bool ReadBool(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.True;
private static bool ReadBoolDefaultTrue(JsonElement o, string name)
=> !o.TryGetProperty(name, out var e) || e.ValueKind != JsonValueKind.False;
}