Merge B1-abcip: v3 RawPath read-path
This commit is contained in:
@@ -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>>= 1</c>. Any other combination (isArray absent/false,
|
||||
/// or isArray:true with arrayLength missing/invalid/<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>>= 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 > 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,
|
||||
|
||||
+16
-23
@@ -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);
|
||||
|
||||
|
||||
+6
-6
@@ -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);
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user