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,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
@@ -48,7 +49,10 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// <summary>
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
/// probe loop would race the operator's own reads.
/// probe loop would race the operator's own reads. Each typed tag is serialised to the v3
/// <see cref="RawTagEntry"/> shape (RawPath = tag name, TagConfig blob via
/// <see cref="AbCipTagDefinitionFactory.ToTagConfig"/>, DeviceName = the tag's device routing key)
/// — the same seam the deploy artifact uses.
/// </summary>
/// <param name="tags">The list of tag definitions to include in the options.</param>
/// <returns>The constructed <see cref="AbCipDriverOptions"/>.</returns>
@@ -58,7 +62,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
HostAddress: Gateway,
PlcFamily: Family,
DeviceName: $"cli-{Family}")],
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(
RawPath: t.Name,
TagConfig: AbCipTagDefinitionFactory.ToTagConfig(t),
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
@@ -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;
}
@@ -82,12 +82,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_discoveredUdtShapes[UdtShapeKey(deviceHostAddress, structName)] = shape;
private readonly PollGroupEngine _poll;
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. TODO(v3 WaveC): collapses to a pure DeviceName index once the connection
// host moves into the Device row's DeviceConfig.
private readonly Dictionary<string, DeviceState> _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly Dictionary<string, AbCipTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Top-level authored definitions (parents + flat tags, NOT synthesised members), in author order —
// the source DiscoverAsync groups by device. Members are synthesised inside the discovery/read paths.
private readonly List<AbCipTagDefinition> _declaredTags = new();
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via AbCipEquipmentTagParser, cached).
// Resolves a read/write/subscribe fullReference (always a RawPath in v3) to a tag definition by a
// single hit on the authored _tagsByRawPath table; a miss is a miss (the pre-v3 blob-parse fallback
// is retired — see EquipmentTagRefResolver).
private readonly EquipmentTagRefResolver<AbCipTagDefinition> _resolver;
private readonly ILogger<AbCipDriver> _logger;
@@ -128,8 +139,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => AbCipEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -248,7 +258,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
{
_options = parsed;
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
@@ -261,18 +271,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
var state = new DeviceState(addr, device, profile);
_devices[device.HostAddress] = state;
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
// host address. Devices with no explicit DeviceName are identified purely by host today.
_devicesByRoutingKey[device.HostAddress] = state;
if (!string.IsNullOrEmpty(device.DeviceName))
_devicesByRoutingKey[device.DeviceName] = state;
}
foreach (var tag in _options.Tags)
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
// Duplicate-key check: a collision means two configured tags have the same name.
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
{
_logger.LogWarning(
"AbCip tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
continue;
}
var tag = mapped with { DeviceHostAddress = entry.DeviceName, WriteIdempotent = entry.WriteIdempotent };
// Duplicate-key check: a collision means two authored tags share the same RawPath.
// Fail fast at init time with a diagnostic rather than silently clobbering.
if (_tagsByName.TryGetValue(tag.Name, out var existingTag))
if (_tagsByRawPath.TryGetValue(tag.Name, out var existingTag))
throw new InvalidOperationException(
$"AbCip tag name collision: '{tag.Name}' is declared more than once. " +
$"Existing entry DeviceHostAddress='{existingTag.DeviceHostAddress}', " +
$"TagPath='{existingTag.TagPath}'. Rename or remove the duplicate.");
_tagsByName[tag.Name] = tag;
_tagsByRawPath[tag.Name] = tag;
_declaredTags.Add(tag);
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
{
@@ -295,12 +326,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Member fan-out duplicate check: a member-path collision means two
// configured structure tags produce the same member path, or a member
// name collides with an independently-declared tag.
if (_tagsByName.TryGetValue(memberTag.Name, out var existingMember))
if (_tagsByRawPath.TryGetValue(memberTag.Name, out var existingMember))
throw new InvalidOperationException(
$"AbCip tag name collision: '{memberTag.Name}' is produced by both " +
$"'{tag.Name}.{member.Name}' (member fan-out) and an existing tag " +
$"'{existingMember.Name}'. Rename one of the configured tags to resolve.");
_tagsByName[memberTag.Name] = memberTag;
_tagsByRawPath[memberTag.Name] = memberTag;
}
}
}
@@ -383,8 +414,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.DisposeHandles();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_devicesByRoutingKey.Clear();
_tagsByRawPath.Clear();
_declaredTags.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -502,19 +535,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IPerCallHostResolver ----
/// <summary>
/// Resolve a read/write/subscribe reference to the device host that keys its per-host
/// resilience (bulkhead / circuit breaker). CONV-4: routes through
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
/// JSON) resolves to its OWN authored device rather than the first-device fallback — else a
/// broken device B would trip device A's breaker. The resolver caches parses, so this adds
/// no per-call cost after first use.
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
/// actual host address (the resilience key), matching by DeviceName-or-host.
/// </summary>
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
/// <param name="fullReference">The tag RawPath reference.</param>
/// <returns>The device host key.</returns>
public string ResolveHost(string fullReference)
{
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
return def.DeviceHostAddress;
if (_resolver.TryResolve(fullReference, out var def)
&& _devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var state))
return state.Options.HostAddress;
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
}
@@ -536,7 +569,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// grouping is itself gated behind EnableDeclarationOnlyUdtGrouping — Studio 5000 may
// reorder UDT members vs declaration order, so the fast path is opt-in only.
var plan = AbCipUdtReadPlanner.Build(
fullReferences, _tagsByName, _options.EnableDeclarationOnlyUdtGrouping);
fullReferences, _tagsByRawPath, _options.EnableDeclarationOnlyUdtGrouping);
foreach (var group in plan.Groups)
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
@@ -564,7 +597,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
return;
@@ -646,7 +679,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var parent = group.ParentDefinition;
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(parent.DeviceHostAddress, out var device))
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
return;
@@ -736,7 +769,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
continue;
}
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
if (!_devicesByRoutingKey.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
continue;
@@ -1017,9 +1050,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Pre-declared tags — always emitted; the primary config path. UDT tags with declared
// Members fan out into a sub-folder + one Variable per member instead of a single
// Structure Variable (Structure has no useful scalar value + member-addressable paths
// are what downstream consumers actually want).
var preDeclared = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
// are what downstream consumers actually want). A tag reaches this device when its
// device routing key matches the device's DeviceName or its host address.
var preDeclared = _declaredTags.Where(t => RoutesToDevice(t, device));
foreach (var tag in preDeclared)
{
if (AbCipSystemTagFilter.IsSystemTag(tag.Name)) continue;
@@ -1235,6 +1268,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// a legacy definition carrying only ElementCount > 1 stays an array for back-compat.
private static bool IsArrayTag(AbCipTagDefinition tag) => tag.IsArray || tag.ElementCount > 1;
// A tag routes to a device when its device routing key (DeviceHostAddress, threaded from the
// RawTagEntry.DeviceName) matches the device's DeviceName or its host address. TODO(v3 WaveC):
// collapses to a DeviceName-only match once the connection host lives on the Device row's DeviceConfig.
private static bool RoutesToDevice(AbCipTagDefinition tag, AbCipDeviceOptions device) =>
string.Equals(tag.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrEmpty(device.DeviceName)
&& string.Equals(tag.DeviceHostAddress, device.DeviceName, StringComparison.OrdinalIgnoreCase));
private static DriverAttributeInfo ToAttributeInfo(AbCipTagDefinition tag) => new(
FullName: tag.Name,
DriverDataType: tag.DataType.ToDriverDataType(),
@@ -1,5 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -68,9 +68,11 @@ public static class AbCipDriverFactoryExtensions
AllowPacking: d.AllowPacking,
ConnectionSize: d.ConnectionSize))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
// v3: the deploy artifact (Wave C) populates RawTags with the cluster's authored raw AbCip
// tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName). The driver maps each entry
// through AbCipTagDefinitionFactory.FromTagConfig at Initialize; the legacy pre-declared DTO
// tag path (structured Tags[] → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new AbCipProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -100,32 +102,6 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
TagPath: t.TagPath ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
Members: t.Members is { Count: > 0 }
? [.. t.Members.Select(m => new AbCipStructureMember(
Name: m.Name ?? throw new InvalidOperationException(
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
$"Members[{m.Name}].DataType"),
Writable: m.Writable ?? true,
WriteIdempotent: m.WriteIdempotent ?? false,
ElementCount: m.ElementCount is > 0 ? m.ElementCount.Value : 1,
IsArray: m.IsArray))]
: null,
SafetyTag: t.SafetyTag ?? false,
ElementCount: t.ElementCount is > 0 ? t.ElementCount.Value : 1,
IsArray: t.IsArray);
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
@@ -182,9 +158,10 @@ public static class AbCipDriverFactoryExtensions
public List<AbCipDeviceDto>? Devices { get; init; }
/// <summary>
/// Gets or sets the list of tags to monitor.
/// Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName)
/// the deploy artifact delivers. The driver maps each through the tag-definition factory at Initialize.
/// </summary>
public List<AbCipTagDto>? Tags { get; init; }
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>
/// Gets or sets the probe configuration.
@@ -220,100 +197,6 @@ public static class AbCipDriverFactoryExtensions
public int? ConnectionSize { get; init; }
}
internal sealed class AbCipTagDto
{
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the device host address.
/// </summary>
public string? DeviceHostAddress { get; init; }
/// <summary>
/// Gets or sets the tag path.
/// </summary>
public string? TagPath { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the tag is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the list of structure members.
/// </summary>
public List<AbCipMemberDto>? Members { get; init; }
/// <summary>
/// Gets or sets whether this is a safety tag.
/// </summary>
public bool? SafetyTag { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal — <c>true</c> ⟺ the tag is an array (even a
/// 1-element one). Mirrors <c>AbCipTagDefinition.IsArray</c>; optional (absent ⇒ scalar).
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array tag; <c>1</c> (or absent) for a scalar.
/// Mirrors <c>AbCipTagDefinition.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipMemberDto
{
/// <summary>
/// Gets or sets the member name.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets or sets the data type.
/// </summary>
public string? DataType { get; init; }
/// <summary>
/// Gets or sets whether the member is writable.
/// </summary>
public bool? Writable { get; init; }
/// <summary>
/// Gets or sets whether write is idempotent.
/// </summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Gets or sets the explicit 1-D array signal for the member — <c>true</c> ⟺ the member is
/// an array (even a 1-element one). Mirrors <c>AbCipStructureMember.IsArray</c>; optional.
/// </summary>
[JsonPropertyName("isArray")]
public bool IsArray { get; init; }
/// <summary>
/// Gets or sets the number of elements for a 1-D array member; <c>1</c> (or absent) for a
/// scalar. Mirrors <c>AbCipStructureMember.ElementCount</c>; only positive values are honoured.
/// </summary>
[JsonPropertyName("elementCount")]
public int? ElementCount { get; init; }
}
internal sealed class AbCipProbeDto
{
/// <summary>
@@ -89,14 +89,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
// Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
// Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
// controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
// is sufficient to confirm CIP reachability.
var tagName = opts.Tags.FirstOrDefault(
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
?.TagPath
?? opts.Tags.FirstOrDefault()?.TagPath
// Prefer the first authored tag path as the probe tag name (it must exist on the PLC). v3: the
// tags arrive as RawTagEntry blobs — map each through the pure factory to recover its TagPath +
// device routing key. Fall back to a benign system attribute present on all ControlLogix /
// CompactLogix controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix,
// which is sufficient to confirm CIP reachability.
var mappedTags = opts.RawTags
.Select(e => AbCipTagDefinitionFactory.FromTagConfig(e.TagConfig, e.RawPath, out var d)
? (Ok: true, Device: e.DeviceName, d.TagPath)
: (Ok: false, Device: e.DeviceName, TagPath: null!))
.Where(x => x.Ok)
.ToList();
var tagName = mappedTags.FirstOrDefault(
x => string.Equals(x.Device, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase)
|| (firstDevice?.DeviceName is { } dn && string.Equals(x.Device, dn, StringComparison.OrdinalIgnoreCase)))
.TagPath
?? mappedTags.FirstOrDefault().TagPath
?? "@raw_cpu_type";
var p = new AbCipTagCreateParams(
@@ -18,9 +18,10 @@ public static class AbCipUdtReadPlanner
{
/// <summary>
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
/// to match the driver's dictionary semantics. When
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByRawPath</c> map (RawPath → def) —
/// both parent UDT rows and their fanned-out member rows live there. Tag lookup honours the
/// passed dictionary's comparer (v3: case-sensitive Ordinal RawPaths); the internal
/// parent-name grouping is OrdinalIgnoreCase. When
/// <paramref name="enableDeclarationOnlyGrouping"/> is <c>false</c> no groups are
/// formed — every reference goes to the per-tag fallback path so member decoding never
/// relies on declaration-order offsets that may not match the controller layout.
@@ -135,9 +135,10 @@ public sealed class AbCipCommandBaseTests
var options = cmd.InvokeBuildOptions(tags);
options.Tags.Count.ShouldBe(2);
options.Tags[0].Name.ShouldBe("t1");
options.Tags[1].Name.ShouldBe("t2");
// v3: BuildOptions projects each typed tag to a RawTagEntry (RawPath = tag name).
options.RawTags.Count.ShouldBe(2);
options.RawTags[0].RawPath.ShouldBe("t1");
options.RawTags[1].RawPath.ShouldBe("t2");
}
/// <summary>
@@ -56,7 +56,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = false, // explicit; also the default
};
var drv = new AbCipDriver(opts, "drv-1", factory);
@@ -83,7 +83,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -138,7 +138,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
EnableDeclarationOnlyUdtGrouping = true,
@@ -189,7 +189,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -230,7 +230,7 @@ public sealed class AbCipAlarmProjectionTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
RawTags = AbCipRawTags.From(AlmdTag("HighTemp")),
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
// The ALMD projection here drives the parent-UDT runtime via offset-keyed values,
@@ -9,20 +9,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
/// <see cref="DriverAttributeInfo.IsArray"/> / <see cref="DriverAttributeInfo.ArrayDim"/>
/// for an array atomic tag + an array UDT member; the read path returns a typed CLR array
/// boxed as <see cref="object"/>; and the equipment-tag resolver threads
/// <c>arrayLength</c> from the TagConfig into the transient definition's element count so
/// an <c>isArray</c> equipment tag reads the whole array.
/// boxed as <see cref="object"/>; and the v3 tag mapper threads <c>arrayLength</c> from the
/// TagConfig into the definition's element count so an <c>isArray</c> tag reads the whole array.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Devices = [new AbCipDeviceOptions(Device)],
RawTags = AbCipRawTags.From(tags),
};
var drv = new AbCipDriver(opts, "drv-array", factory);
return (drv, factory);
@@ -36,8 +37,8 @@ public sealed class AbCipArrayTests
{
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 10),
new AbCipTagDefinition("Single", "ab://10.0.0.5/1,0", "Single", AbCipDataType.DInt));
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 10, IsArray: true),
new AbCipTagDefinition("Single", Device, "Single", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
@@ -57,10 +58,10 @@ public sealed class AbCipArrayTests
{
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(
new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]));
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -83,7 +84,7 @@ public sealed class AbCipArrayTests
public async Task Array_DInt_read_returns_typed_int_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", "ab://10.0.0.5/1,0", "Recipe", AbCipDataType.DInt, ElementCount: 4));
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 });
@@ -101,7 +102,7 @@ public sealed class AbCipArrayTests
public async Task Array_Real_read_returns_typed_float_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Floats", "ab://10.0.0.5/1,0", "Floats", AbCipDataType.Real, ElementCount: 3));
new AbCipTagDefinition("Floats", Device, "Floats", AbCipDataType.Real, ElementCount: 3, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f });
@@ -116,7 +117,7 @@ public sealed class AbCipArrayTests
public async Task Array_Bool_read_returns_typed_bool_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Flags", "ab://10.0.0.5/1,0", "Flags", AbCipDataType.Bool, ElementCount: 3));
new AbCipTagDefinition("Flags", Device, "Flags", AbCipDataType.Bool, ElementCount: 3, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true });
@@ -131,7 +132,7 @@ public sealed class AbCipArrayTests
public async Task Array_String_read_returns_typed_string_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Names", "ab://10.0.0.5/1,0", "Names", AbCipDataType.String, ElementCount: 2));
new AbCipTagDefinition("Names", Device, "Names", AbCipDataType.String, ElementCount: 2, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" });
@@ -146,7 +147,7 @@ public sealed class AbCipArrayTests
public async Task Scalar_read_path_unchanged_for_element_count_one()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
@@ -158,20 +159,18 @@ public sealed class AbCipArrayTests
/// <summary>
/// Driver.AbCip-016 — a DECLARED UDT member that is a 1-D array (<c>Setpoints : REAL[4]</c>)
/// must READ as a typed CLR array, matching the array node it discovers as. Before the fix
/// the member fan-out in <c>InitializeAsync</c> dropped the member's <c>ElementCount</c> /
/// <c>IsArray</c>, so the fanned-out runtime definition defaulted to scalar and the read
/// returned a single element (or null) instead of the array — a declared-type-vs-runtime-value
/// mismatch.
/// must READ as a typed CLR array, matching the array node it discovers as. The member fan-out
/// in <c>InitializeAsync</c> threads the member's <c>ElementCount</c> / <c>IsArray</c> into the
/// fanned-out runtime definition.
/// </summary>
[Fact]
public async Task Declared_udt_array_member_reads_as_typed_array()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Motor", "ab://10.0.0.5/1,0", "Motor", AbCipDataType.Structure,
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]));
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -187,24 +186,18 @@ public sealed class AbCipArrayTests
factory.Tags["Motor.Setpoints"].CreationParams.IsArray.ShouldBeTrue();
}
// ---- Resolver: arrayLength threading ----
// ---- Mapper: arrayLength threading ----
/// <summary>The equipment-tag resolver threads arrayLength into the def's ElementCount.</summary>
/// <summary>An authored array tag reads as a typed CLR array end-to-end.</summary>
[Fact]
public async Task Equipment_ref_with_arrayLength_reads_as_a_typed_array()
public async Task Authored_array_tag_reads_as_a_typed_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":4}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-array", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 });
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
@@ -212,80 +205,75 @@ public sealed class AbCipArrayTests
factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
}
/// <summary>The parser threads arrayLength into the transient definition's ElementCount and sets IsArray.</summary>
/// <summary>The mapper threads arrayLength into the definition's ElementCount and sets IsArray.</summary>
[Fact]
public void Parser_threads_arrayLength_into_ElementCount()
public void Mapper_threads_arrayLength_into_ElementCount()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.ElementCount.ShouldBe(8);
def.IsArray.ShouldBeTrue();
}
/// <summary>A non-array equipment ref defaults ElementCount to 1 (scalar) and IsArray false.</summary>
/// <summary>A non-array TagConfig defaults ElementCount to 1 (scalar) and IsArray false.</summary>
[Fact]
public void Parser_defaults_ElementCount_to_one_when_not_an_array()
public void Mapper_defaults_ElementCount_to_one_when_not_an_array()
{
var json = """{"tagPath":"Recipe","dataType":"DInt"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.ElementCount.ShouldBe(1);
def.IsArray.ShouldBeFalse();
}
/// <summary>
/// Review finding I-2 — <c>isArray:true</c> with NO <c>arrayLength</c> is a DEGENERATE input
/// that must parse as SCALAR (IsArray false, ElementCount 1), matching every other driver
/// (Modbus, S7, TwinCAT, AbLegacy). The AdminUI validator blocks authoring this combination,
/// but the parser contract must still be consistent cross-driver.
/// that must map as SCALAR (IsArray false, ElementCount 1), matching every other driver.
/// </summary>
[Fact]
public void Parser_isArray_true_without_arrayLength_parses_as_scalar()
public void Mapper_isArray_true_without_arrayLength_maps_as_scalar()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-2 — <c>isArray:true</c> with an invalid (zero) <c>arrayLength</c> also
/// parses as SCALAR, consistent with the cross-driver rule.
/// maps as SCALAR, consistent with the cross-driver rule.
/// </summary>
[Fact]
public void Parser_isArray_true_with_zero_arrayLength_parses_as_scalar()
public void Mapper_isArray_true_with_zero_arrayLength_maps_as_scalar()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-1 — a 1-element array (<c>isArray:true, arrayLength:1</c>) is a valid
/// 1-element array, NOT a scalar: the parser sets <see cref="AbCipTagDefinition.IsArray"/>
/// true and <see cref="AbCipTagDefinition.ElementCount"/> 1.
/// 1-element array, NOT a scalar: the mapper sets IsArray true and ElementCount 1.
/// </summary>
[Fact]
public void Parser_treats_isArray_with_arrayLength_one_as_a_one_element_array()
public void Mapper_treats_isArray_with_arrayLength_one_as_a_one_element_array()
{
var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
/// <summary>
/// Review finding I-1 — <c>isArray:true, arrayLength:1</c> must DISCOVER as a [1] array node
/// (IsArray + ArrayDim 1), matching the foundation's materialisation, not as a scalar.
/// Review finding I-1 — a 1-element array tag must DISCOVER as a [1] array node (IsArray +
/// ArrayDim 1), matching the foundation's materialisation, not as a scalar.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_arrayLength_one_discovers_as_one_element_array()
public async Task Authored_one_element_array_discovers_as_one_element_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
var builder = new RecordingBuilder();
var (drv, _) = NewDriver(def!);
var (drv, _) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
@@ -296,25 +284,17 @@ public sealed class AbCipArrayTests
}
/// <summary>
/// Review finding I-1 — the I-1 case: an <c>isArray:true, arrayLength:1</c> equipment tag
/// reads a 1-ELEMENT typed array, NOT a scalar. On current code (gate <c>ElementCount &gt; 1</c>)
/// this reads a scalar; the explicit IsArray flag fixes it.
/// Review finding I-1 — a 1-element array tag reads a 1-ELEMENT typed array, NOT a scalar.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_arrayLength_one_reads_as_one_element_array()
public async Task Authored_one_element_array_reads_as_one_element_array()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-array1", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 99 });
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
@@ -324,24 +304,18 @@ public sealed class AbCipArrayTests
}
/// <summary>
/// Regression — a genuinely scalar equipment ref (<c>isArray:false</c>) reads a boxed
/// scalar via <see cref="IAbCipTagRuntime.DecodeValue"/>, never an array.
/// Regression — a genuinely scalar tag (<c>isArray:false</c>) reads a boxed scalar via
/// <see cref="IAbCipTagRuntime.DecodeValue"/>, never an array.
/// </summary>
[Fact]
public async Task Equipment_ref_isArray_false_reads_as_scalar()
public async Task Scalar_tag_reads_as_scalar()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Speed","dataType":"DInt","isArray":false}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
};
var drv = new AbCipDriver(opts, "abcip-eq-scalar", factory);
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
snapshots.Single().Value.ShouldBe(4200);
snapshots.Single().Value.ShouldNotBeOfType<int[]>();
@@ -29,10 +29,9 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -60,7 +59,7 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -86,7 +85,7 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -108,11 +107,10 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
],
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -135,11 +133,10 @@ public sealed class AbCipBoolInDIntRmwTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
],
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -17,7 +17,7 @@ public sealed class AbCipConnectBackoffTests
private static AbCipDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = probeEnabled,
@@ -18,17 +18,18 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
/// <summary>Tests that InitializeAsync applies devices and tags from the config JSON.</summary>
/// <summary>Tests that InitializeAsync applies devices and raw tags from the config JSON.</summary>
[Fact]
public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json()
{
// Constructed with NO devices/tags — the JSON is the only source of config.
// Constructed with NO devices/tags — the JSON is the only source of config. v3: tags arrive as
// RawTags (RawPath + TagConfig blob + DeviceName routing key).
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
const string json = """
{
"Devices": [ { "HostAddress": "ab://10.0.0.9/1,0", "PlcFamily": "ControlLogix" } ],
"Tags": [ { "Name": "Speed", "DeviceHostAddress": "ab://10.0.0.9/1,0",
"TagPath": "Speed", "DataType": "DInt" } ]
"RawTags": [ { "RawPath": "line1/speed", "TagConfig": "{\"tagPath\":\"Speed\",\"dataType\":\"DInt\"}",
"WriteIdempotent": false, "DeviceName": "ab://10.0.0.9/1,0" } ]
}
""";
@@ -92,14 +93,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
]),
],
])),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -186,7 +185,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -228,7 +227,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", Device, "Counter", AbCipDataType.UDInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -254,14 +253,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
]),
],
])),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -281,11 +278,9 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real), // same name
],
new AbCipTagDefinition("Speed", Device, "SpeedAlias", AbCipDataType.Real)), // same name
}, "drv-1");
Should.Throw<InvalidOperationException>(() =>
@@ -301,14 +296,12 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt), // collision
],
new AbCipTagDefinition("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt)), // collision
}, "drv-1");
Should.Throw<InvalidOperationException>(() =>
@@ -336,7 +329,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -19,11 +19,9 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false),
],
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -61,12 +59,10 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt),
],
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -83,7 +79,7 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -418,21 +414,19 @@ public sealed class AbCipDriverDiscoveryTests
}
/// <summary>
/// A discovered member-path read goes end-to-end through the driver: with NO pre-declared
/// tag for the member, <see cref="AbCipDriver.ReadAsync"/> resolves the authored TagConfig
/// JSON blob (FullName = <c>Parent.Member</c>) via the equipment-tag resolver, materialises a
/// runtime on the device, reads it, and decodes the member's atomic value — proving discovered
/// members are readable with no extra registration. The libplctag tag name the driver builds
/// for the member equals the dotted member path.
/// A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig
/// addresses a dotted member path (<c>Motor1.Status.Code</c>) is delivered as a
/// <see cref="RawTagEntry"/>, resolves by its RawPath, materialises a runtime on the device, and
/// decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted
/// member path (the TagConfig's tagPath), independent of the RawPath identity.
/// </summary>
[Fact]
public async Task Discovered_member_path_reads_through_driver_as_member_atomic_type()
public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
{
const string device = "ab://10.0.0.5/1,0";
// The wire reference handed to ReadAsync for a discovered member is the authored TagConfig
// JSON blob (FullName = Motor1.Status.Code, atomic type Real here). No tag is pre-declared.
const string tagConfig =
"{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\",\"deviceHostAddress\":\"ab://10.0.0.5/1,0\"}";
const string rawPath = "line1/motor1/status/code";
// The authored TagConfig addresses the dotted member path (atomic Real here).
const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}";
var tagFactory = new FakeAbCipTagFactory
{
@@ -443,16 +437,17 @@ public sealed class AbCipDriverDiscoveryTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
}, "drv-1", tagFactory: tagFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.ReadAsync([tagConfig], CancellationToken.None);
var results = await drv.ReadAsync([rawPath], CancellationToken.None);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[0].Value.ShouldBe(12.5f);
// The driver built a runtime under the dotted member tag name — confirming the member-path
// read addresses the member directly (no parent-UDT registration needed).
// read addresses the member directly.
tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
}
@@ -15,7 +15,7 @@ public sealed class AbCipDriverReadTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
return (drv, factory);
@@ -42,7 +42,7 @@ public sealed class AbCipDriverReadTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -19,7 +19,7 @@ public sealed class AbCipDriverWholeUdtReadTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
// Whole-UDT grouping is opt-in (Driver.AbCip-003) — these tests exercise the
// grouping fast path, so they switch it on explicitly.
EnableDeclarationOnlyUdtGrouping = true,
@@ -15,7 +15,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
}, "drv-1", factory);
return (drv, factory);
}
@@ -74,7 +74,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -115,7 +115,7 @@ public sealed class AbCipDriverWriteTests
var drv2 = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)),
}, "drv-2", factory);
await drv2.InitializeAsync("{}", CancellationToken.None);
@@ -133,7 +133,7 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)),
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -167,12 +167,11 @@ public sealed class AbCipDriverWriteTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, Writable: false),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
],
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -12,14 +12,14 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Typo_dataType_rejects_the_tag()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", out _)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}", "line1/eq/tag", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(AbCipDataType.DInt);
}
@@ -27,7 +27,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
var warnings = AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
@@ -36,13 +36,13 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
AbCipEquipmentTagParser.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
AbCipTagDefinitionFactory.Inspect("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured_bit_identical()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\",\"writable\":false}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
@@ -50,7 +50,7 @@ public sealed class AbCipEquipmentTagParserStrictnessTests
[Fact]
public void Writable_absent_defaults_to_true()
{
AbCipEquipmentTagParser.TryParse("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", out var def)
AbCipTagDefinitionFactory.FromTagConfig("{\"tagPath\":\"Motor.Speed\",\"dataType\":\"DInt\"}", "line1/eq/tag", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
@@ -1,142 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipEquipmentTagParser.TryParse"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path (Driver.AbCip.Contracts-004).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipEquipmentTagParserTests
{
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
// ── Writable field (Driver.AbCip.Contracts-001) ───────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ─────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not supported in the
/// equipment-tag flow. This test documents current behaviour so a future change to reject
/// Structure is a conscious choice.
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
}
@@ -7,14 +7,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public class AbCipEquipmentTagTests
{
private const string Raw = "line1/eq/tag";
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
public void Parses_equipment_tagconfig_into_a_definition()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("Motor1.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.5/1,0");
def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
}
@@ -23,83 +25,82 @@ public class AbCipEquipmentTagTests
public void Defaults_optional_fields_when_only_the_mandatory_tagPath_is_present()
{
var json = """{"tagPath":"PT_101"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("PT_101");
def.DeviceHostAddress.ShouldBe(""); // absent → empty, matching AbCipTagConfigModel default
def.DeviceHostAddress.ShouldBe(""); // routing key travels on the RawTagEntry, not the blob
def.DataType.ShouldBe(AbCipDataType.DInt); // AbCipTagConfigModel's default atomic type
}
[Fact]
public void Rejects_a_non_abcip_blob()
=> AbCipEquipmentTagParser.TryParse("""{"region":"x"}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"region":"x"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_a_blob_with_an_empty_tagPath()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" ","dataType":"DInt"}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" ","dataType":"DInt"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_tagPath_given_as_a_non_string()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> AbCipEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
[Fact]
public void Rejects_a_plain_authored_name_without_a_leading_brace()
=> AbCipEquipmentTagParser.TryParse("Motor1.Speed", out _).ShouldBeFalse();
=> AbCipTagDefinitionFactory.FromTagConfig("not json", Raw, out _).ShouldBeFalse();
/// <summary>
/// End-to-end driver-level proof: an AbCip driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
/// definition (whose DeviceHostAddress names a configured device) and the read goes to the
/// wire instead of returning BadNodeIdUnknown.
/// End-to-end driver-level proof: an authored raw AbCip tag (RawPath + TagConfig blob delivered as a
/// <c>RawTagEntry</c>) resolves by its RawPath and the read goes to the wire instead of returning
/// BadNodeIdUnknown. This is the v3 replacement for the retired blob-parse-fallback resolver.
/// </summary>
[Fact]
public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
public async Task Driver_resolves_a_raw_tag_and_reads_instead_of_BadNodeIdUnknown()
{
var json = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DInt"}""";
const string device = "ab://10.0.0.5/1,0";
const string rawPath = "line1/motor/speed";
var tagConfig = """{"tagPath":"Motor1.Speed","dataType":"DInt"}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
};
var drv = new AbCipDriver(opts, "abcip-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 };
var snapshots = await drv.ReadAsync([json], CancellationToken.None);
var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots.Single().StatusCode.ShouldNotBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBe(4242);
// The runtime is keyed by the resolved libplctag tag path, not the RawPath identity.
factory.Tags.ShouldContainKey("Motor1.Speed");
}
/// <summary>
/// End-to-end driver-level proof of the R2-11 Phase C flip: an equipment-tag ref carrying a
/// TYPO'd <c>dataType</c> enum now REJECTS at the resolver (strict parse ⇒ <c>TryParse</c> false)
/// so the read surfaces <see cref="AbCipStatusMapper.BadNodeIdUnknown"/> instead of the old
/// lenient behaviour that silently defaulted to <c>DInt</c> and published a wrong-width
/// <c>Good</c>. Same driver + device wiring as the clean-read proof above; only the enum is
/// mistyped.
/// R2-11 Phase C strictness end-to-end: a raw tag whose TagConfig carries a TYPO'd <c>dataType</c>
/// fails to map (<c>FromTagConfig</c> ⇒ false) so it never enters the driver's RawPath table; a read
/// of that RawPath surfaces <see cref="AbCipStatusMapper.BadNodeIdUnknown"/> instead of a
/// silently-defaulted wrong-width Good.
/// </summary>
[Fact]
public async Task Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown()
public async Task Driver_read_of_a_typod_dataType_tag_surfaces_BadNodeIdUnknown()
{
var typoJson = """{"deviceHostAddress":"ab://10.0.0.5/1,0","tagPath":"Motor1.Speed","dataType":"DIntt"}""";
const string device = "ab://10.0.0.5/1,0";
const string rawPath = "line1/motor/speed";
var typoConfig = """{"tagPath":"Motor1.Speed","dataType":"DIntt"}""";
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [],
Devices = [new AbCipDeviceOptions(device)],
RawTags = [new global::ZB.MOM.WW.OtOpcUa.Core.Abstractions.RawTagEntry(rawPath, typoConfig, WriteIdempotent: false, DeviceName: device)],
};
var drv = new AbCipDriver(opts, "abcip-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 4242 };
var snapshots = await drv.ReadAsync([typoJson], CancellationToken.None);
var snapshots = await drv.ReadAsync([rawPath], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
@@ -5,18 +5,18 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Regression coverage for Driver.AbCip-018 — the driver-config factory path
/// (<see cref="AbCipDriverFactoryExtensions.ParseOptions"/>) dropped the array shape of a
/// pre-declared tag. A <c>tags[]</c> entry with <c>"isArray": true, "elementCount": 4</c>
/// deserialised into a scalar <see cref="AbCipTagDefinition"/> (because <c>AbCipTagDto</c> /
/// <c>AbCipMemberDto</c> had no <c>ElementCount</c> / <c>IsArray</c> fields), so the tag read
/// as a single scalar despite the explicit array declaration. These tests assert the array
/// shape now survives factory deserialization for both top-level tags and UDT members, and
/// that omitting the fields keeps the legacy scalar default (additive change — no break).
/// ParseOptions timeout hardening + the array-shape round-trip through the v3 tag mapper. Under v3 the
/// factory no longer builds typed tag definitions (it carries authored <c>RawTags</c> blobs verbatim);
/// the array/member shape survives the TagConfig round-trip in
/// <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> instead. These tests assert the array shape
/// survives mapping for both top-level tags and declared UDT members, and that omitting the fields keeps
/// the scalar default.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipFactoryArrayTagTests
{
private const string Raw = "line1/eq/tag";
/// <summary>
/// A non-positive <c>TimeoutMs</c> clamps to the 2 s default rather than reaching libplctag's
/// <c>Tag.Timeout</c> setter (which throws <see cref="ArgumentOutOfRangeException"/> for a
@@ -44,103 +44,56 @@ public sealed class AbCipFactoryArrayTagTests
opts.Timeout.ShouldBe(TimeSpan.FromMilliseconds(500));
}
/// <summary>A driver-config tag declaring isArray/elementCount produces an array definition.</summary>
/// <summary>A TagConfig declaring isArray/arrayLength maps to an array definition.</summary>
[Fact]
public void ParseOptions_threads_isArray_and_elementCount_into_tag_definition()
public void FromTagConfig_threads_isArray_and_arrayLength_into_tag_definition()
{
const string json = """
{
"Tags": [ {
"Name": "Setpoints",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Setpoints",
"DataType": "Real",
"isArray": true,
"elementCount": 4
} ]
}
""";
const string tagConfig = """{"tagPath":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
tag.IsArray.ShouldBeTrue();
tag.ElementCount.ShouldBe(4);
}
/// <summary>A driver-config UDT member declaring isArray/elementCount produces an array member.</summary>
/// <summary>A declared UDT member declaring isArray/arrayLength maps to an array member.</summary>
[Fact]
public void ParseOptions_threads_isArray_and_elementCount_into_structure_member()
public void FromTagConfig_threads_isArray_and_arrayLength_into_structure_member()
{
const string json = """
{
"Tags": [ {
"Name": "Motor",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Motor",
"DataType": "Structure",
"Members": [ {
"Name": "Setpoints",
"DataType": "Real",
"isArray": true,
"elementCount": 4
} ]
} ]
}
const string tagConfig = """
{"tagPath":"Motor","dataType":"Structure",
"members":[{"name":"Setpoints","dataType":"Real","isArray":true,"arrayLength":4}]}
""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var member = opts.Tags.Single().Members!.Single();
var member = tag.Members!.Single();
member.IsArray.ShouldBeTrue();
member.ElementCount.ShouldBe(4);
}
/// <summary>A driver-config tag without array fields stays scalar (additive change — no break).</summary>
/// <summary>A TagConfig without array fields stays scalar.</summary>
[Fact]
public void ParseOptions_defaults_to_scalar_when_array_fields_absent()
public void FromTagConfig_defaults_to_scalar_when_array_fields_absent()
{
const string json = """
{
"Tags": [ {
"Name": "Speed",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Speed",
"DataType": "DInt"
} ]
}
""";
const string tagConfig = """{"tagPath":"Speed","dataType":"DInt"}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
tag.IsArray.ShouldBeFalse();
tag.ElementCount.ShouldBe(1);
}
/// <summary>A non-positive elementCount falls back to the scalar count of 1 (positive-value guard).</summary>
/// <summary>A non-positive arrayLength falls back to the scalar count of 1 (canonical rule: scalar).</summary>
[Fact]
public void ParseOptions_guards_against_non_positive_elementCount()
public void FromTagConfig_guards_against_non_positive_arrayLength()
{
const string json = """
{
"Tags": [ {
"Name": "Speed",
"DeviceHostAddress": "ab://10.0.0.5/1,0",
"TagPath": "Speed",
"DataType": "DInt",
"isArray": true,
"elementCount": 0
} ]
}
""";
const string tagConfig = """{"tagPath":"Speed","dataType":"DInt","isArray":true,"arrayLength":0}""";
var opts = AbCipDriverFactoryExtensions.ParseOptions("drv-1", json);
AbCipTagDefinitionFactory.FromTagConfig(tagConfig, Raw, out var tag).ShouldBeTrue();
var tag = opts.Tags.Single();
// elementCount <= 0 is degenerate — count clamps to 1; the explicit IsArray flag still
// carries (a 1-element array is valid), mirroring the equipment-tag parser's contract.
// arrayLength <= 0 with isArray:true is degenerate — canonical rule collapses to scalar.
tag.ElementCount.ShouldBe(1);
tag.IsArray.ShouldBeTrue();
tag.IsArray.ShouldBeFalse();
}
}
@@ -172,11 +172,10 @@ public sealed class AbCipHostProbeTests
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
],
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -216,11 +215,10 @@ public sealed class AbCipHostProbeTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -93,7 +93,7 @@ public sealed class AbCipLoggingTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -124,7 +124,7 @@ public sealed class AbCipLoggingTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory, logger: logger);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -29,7 +29,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AllowPacking: false)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -69,7 +69,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(micro, AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", micro, "X", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -88,7 +88,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, ConnectionSize: 504)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -106,7 +106,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -109,7 +109,7 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -141,12 +141,11 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Writable: true, SafetyTag: true))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -167,11 +166,10 @@ public sealed class AbCipPlcFamilyTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Writable: true, SafetyTag: true))
,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -0,0 +1,26 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Test helper bridging the pre-v3 pre-declared-tag authoring style (a typed
/// <see cref="AbCipTagDefinition"/>) to the v3 RawPath seam the driver now consumes
/// (<see cref="RawTagEntry"/>). Each typed def is serialised to its <c>TagConfig</c> blob via
/// <see cref="AbCipTagDefinitionFactory.ToTagConfig"/>, and the def's identity (<c>Name</c> → RawPath),
/// device routing key (<c>DeviceHostAddress</c> → <see cref="RawTagEntry.DeviceName"/>) and write-idempotence
/// travel on the entry — exactly the shape the deploy artifact delivers. Lets the driver's read / write /
/// discovery suites keep expressing fixtures as typed definitions while exercising the v3
/// <c>RawTags</c> → <c>FromTagConfig</c> table-build path.
/// </summary>
internal static class AbCipRawTags
{
/// <summary>Projects typed definitions to the driver's <c>RawTags</c> option shape.</summary>
/// <param name="defs">The typed definitions to project.</param>
/// <returns>The equivalent <see cref="RawTagEntry"/> list.</returns>
public static IReadOnlyList<RawTagEntry> From(params AbCipTagDefinition[] defs) =>
[.. defs.Select(d => new RawTagEntry(
RawPath: d.Name,
TagConfig: AbCipTagDefinitionFactory.ToTagConfig(d),
WriteIdempotent: d.WriteIdempotent,
DeviceName: d.DeviceHostAddress))];
}
@@ -1,14 +1,15 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// CONV-4 — <see cref="AbCipDriver.ResolveHost"/> keys per-host resilience (bulkhead / circuit
/// breaker). It must resolve an equipment-tag reference (raw TagConfig JSON) to its OWN device
/// host, not fall back to the first device — otherwise a broken device B trips device A's
/// breaker. Authored tags and unknown refs keep the current fallback.
/// breaker). It must resolve an authored tag (by its RawPath) to its OWN device host, not fall back
/// to the first device — otherwise a broken device B trips device A's breaker. Unknown refs keep the
/// current first-device fallback.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipResolveHostTests
@@ -16,21 +17,27 @@ public sealed class AbCipResolveHostTests
private const string DeviceA = "ab://10.0.0.5/1,0";
private const string DeviceB = "ab://10.0.0.6/1,0";
private static AbCipDriver NewDriver() => new(
private static AbCipDriver NewDriver(params RawTagEntry[] rawTags) => new(
new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(DeviceA), new AbCipDeviceOptions(DeviceB)],
RawTags = rawTags,
Probe = new AbCipProbeOptions { Enabled = false },
},
"abcip-1", new FakeAbCipTagFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
/// <summary>An authored tag routed to device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
public async Task ResolveHost_TagRoutedToDeviceB_ReturnsItsOwnDeviceHost()
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","tagPath":"Motor1.Speed","dataType":"DInt"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
var drv = NewDriver(new RawTagEntry(
RawPath: "line1/motorB/speed",
TagConfig: """{"tagPath":"Motor1.Speed","dataType":"DInt"}""",
WriteIdempotent: false,
DeviceName: DeviceB));
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("line1/motorB/speed").ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
@@ -70,7 +70,7 @@ public sealed class AbCipRuntimeConcurrencyTests
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
};
return new AbCipDriver(opts, "drv-1", factory);
}
@@ -15,7 +15,7 @@ public sealed class AbCipSubscriptionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
RawTags = AbCipRawTags.From(tags),
}, "drv-1", factory);
return (drv, factory);
}
@@ -161,11 +161,10 @@ public sealed class AbCipSubscriptionTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName == "Motor1.Speed"
@@ -0,0 +1,182 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipTagDefinitionFactory.FromTagConfig"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path. Under v3 the definition's
/// identity (<c>Name</c>) is the RawPath the mapper is handed, not the blob, and the mapper does
/// NOT read <c>deviceHostAddress</c> (device placement travels on the RawTagEntry).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipTagDefinitionFactoryTests
{
private const string Raw = "line1/eq/tag";
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Name.ShouldBe(Raw);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe(""); // v3: mapper never reads deviceHostAddress
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("not json at all", Raw, out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""["tagPath","foo"]""", Raw, out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("\"Motor.Speed\"", Raw, out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"dataType":"DInt"}""", Raw, out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":" "}""", Raw, out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipTagDefinitionFactory.FromTagConfig("""{"tagPath":42}""", Raw, out _).ShouldBeFalse();
// ── Writable field ────────────────────────────────────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType ────────────────────────────────────────────────────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not authored via the
/// production equipment-tag flow (AbCipTagConfigModel emits no members key).
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipTagDefinitionFactory.FromTagConfig(json, Raw, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
// ── ToTagConfig / FromTagConfig round-trip (v3 RawTagEntry carrier) ────────────────────
/// <summary>
/// A typed definition serialised via <see cref="AbCipTagDefinitionFactory.ToTagConfig"/> and
/// re-read via <see cref="AbCipTagDefinitionFactory.FromTagConfig"/> reproduces every per-tag
/// field except the out-of-band device routing key + WriteIdempotent (which travel on the
/// RawTagEntry, not the blob). Covers the array + declared-member round-trip the driver-test
/// AbCipRawTags helper relies on.
/// </summary>
[Fact]
public void ToTagConfig_FromTagConfig_round_trips_arrays_and_members()
{
var def = new AbCipTagDefinition(
Name: "Motor1", DeviceHostAddress: "ab://10.0.0.5/1,0", TagPath: "Motor1",
DataType: AbCipDataType.Structure, Writable: false, SafetyTag: true,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Buf", AbCipDataType.Real, Writable: false, ElementCount: 4, IsArray: true),
]);
var blob = AbCipTagDefinitionFactory.ToTagConfig(def);
AbCipTagDefinitionFactory.FromTagConfig(blob, "Motor1", out var back).ShouldBeTrue();
back!.Name.ShouldBe("Motor1");
back.DeviceHostAddress.ShouldBe(""); // routing key not in the blob
back.TagPath.ShouldBe("Motor1");
back.DataType.ShouldBe(AbCipDataType.Structure);
back.Writable.ShouldBeFalse();
back.SafetyTag.ShouldBeTrue();
back.Members!.Count.ShouldBe(2);
back.Members[0].Name.ShouldBe("Speed");
back.Members[1].IsArray.ShouldBeTrue();
back.Members[1].ElementCount.ShouldBe(4);
back.Members[1].Writable.ShouldBeFalse();
}
}
@@ -16,8 +16,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition(
Name: "Motor1",
DeviceHostAddress: "ab://10.0.0.5/1,0",
@@ -28,8 +27,8 @@ public sealed class AbCipUdtMemberTests
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
]),
],
]))
,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -63,15 +62,14 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -91,14 +89,13 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -117,14 +114,13 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
]),
],
]))
,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -145,7 +141,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -163,7 +159,7 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -181,16 +177,15 @@ public sealed class AbCipUdtMemberTests
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
],
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real))
,
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);