merge: Task 12 — native AbCip tag runtime, factory and device mapping

This commit is contained in:
Joseph Doherty
2026-10-07 16:58:37 -04:00
8 changed files with 1006 additions and 5 deletions
@@ -1171,7 +1171,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
var runtime = _tagFactory.Create(
device.BuildCreateParams(
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def)));
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def),
authoredType: def.DataType));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -1686,9 +1687,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
/// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
/// 1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
/// <param name="authoredType">The tag definition's authored type, for the native runtime's
/// returned-type check; null skips it.</param>
/// <returns>The computed tag creation parameters.</returns>
public AbCipTagCreateParams BuildCreateParams(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false,
AbCipDataType? authoredType = null) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
@@ -1698,7 +1702,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
ElementCount: elementCount < 1 ? 1 : elementCount,
IsArray: isArray);
IsArray: isArray,
AuthoredType: authoredType,
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
public void DisposeHandles()
@@ -119,7 +119,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout,
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = _tagFactory.Create(p);
try
@@ -119,6 +119,13 @@ public interface IAbCipTagFactory
/// element count for an <c>isArray</c> tag — including a 1-element array.</param>
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal threaded from the tag definition so
/// a 1-element array (<see cref="ElementCount"/> 1) is still read as an array, not a scalar.</param>
/// <param name="AuthoredType">The tag definition's authored data type, when known. The native runtime
/// checks the CIP type code the controller returns against it (a REAL authored as DINT is
/// <c>TypeMismatch</c>); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents)
/// skips the check.</param>
/// <param name="UseConnectedMessaging">From <c>AbCipPlcFamilyProfile.SupportsConnectedMessaging</c> at
/// every construction site, so the family profile stays the single authority for the native
/// transport's routing choice. libplctag ignores it.</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -129,7 +136,9 @@ public sealed record AbCipTagCreateParams(
bool AllowPacking = true,
int ConnectionSize = 4002,
int ElementCount = 1,
bool IsArray = false)
bool IsArray = false,
AbCipDataType? AuthoredType = null,
bool UseConnectedMessaging = true)
{
/// <summary>
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Maps <see cref="AbCipTagCreateParams"/> onto the native transport's connection identity and
/// settings. Shared by the native tag runtime, enumerator and template reader so every handle on
/// one device leases the same pooled connection.
/// </summary>
internal static class NativeAbCipDevice
{
/// <summary>The EtherNet/IP default TCP port, used when no usable port was configured.</summary>
internal const int DefaultPort = 44818;
/// <summary>The <c>plc=</c> attribute of the Micro800 family profile.</summary>
private const string Micro800Attribute = "micro800";
/// <summary>The pool key: one CIP connection per <c>(host, port, cipPath, plcAttribute)</c>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The device key.</returns>
public static DeviceKey Key(AbCipTagCreateParams p) =>
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
/// <summary>The connection settings for the device <paramref name="p"/> addresses.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The connection options.</returns>
public static ConnectionOptions Options(AbCipTagCreateParams p) => new()
{
Routing = RoutingFor(p),
Timeout = p.Timeout,
ConnectionSize = p.ConnectionSize,
AllowPacking = p.AllowPacking,
};
/// <summary>
/// How requests reach the controller's message router.
/// </summary>
/// <remarks>
/// Micro800 is <b>always</b> <see cref="CipRouting.Connected"/> with an empty route, even though its
/// family profile says <c>SupportsConnectedMessaging: false</c>: libplctag opens a Large Forward
/// Open with connection path <c>20 02 24 01</c> and reads connected (Captures/micro800-read-dint.hex,
/// Captures/README.md "Routing observed"; ruling R12-1 — the capture beats the profile flag).
/// Every other family follows the profile: connected-capable ⇒ Forward Open; otherwise
/// Unconnected Send when a route is configured, bare SendRRData when it is not.
/// </remarks>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The routing mode.</returns>
internal static CipRouting RoutingFor(AbCipTagCreateParams p)
{
if (string.Equals(p.LibplctagPlcAttribute, Micro800Attribute, StringComparison.OrdinalIgnoreCase))
return CipRouting.Connected;
if (p.UseConnectedMessaging) return CipRouting.Connected;
return string.IsNullOrWhiteSpace(p.CipPath) ? CipRouting.Direct : CipRouting.UnconnectedSend;
}
}
@@ -0,0 +1,15 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// <see cref="IAbCipTagFactory"/> for the native transport: every runtime leases its device's
/// connection from <see cref="DeviceSessionPool.Shared"/>, so all tags on one device share one CIP
/// connection and one request scheduler. Stateless; safe to share across devices.
/// </summary>
internal sealed class NativeAbCipTagFactory : IAbCipTagFactory
{
/// <inheritdoc />
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
new NativeAbCipTagRuntime(createParams, DeviceSessionPool.Shared);
}
@@ -0,0 +1,498 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTagRuntime"/> over the managed EtherNet/IP + CIP stack. Leases the
/// device's shared connection from <see cref="DeviceSessionPool.Shared"/> and reads/writes through a
/// per-runtime <see cref="LogixTagClient"/> over the lease's <b>shared</b> scheduler, so concurrent
/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors
/// <see cref="LibplctagTagRuntime"/> byte for byte; unlike libplctag it also checks the CIP type
/// code the controller returned against the authored type.
/// </summary>
/// <remarks>
/// Not thread-safe: the driver serialises Read → GetStatus → Decode (and Encode → Write → GetStatus)
/// per runtime under its per-runtime lock (05/STAB-4), exactly as it does for libplctag.
/// </remarks>
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
/// <summary>Upper bound on waiting for the lease release (and its Forward Close) in <see cref="Dispose"/>.</summary>
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
/// <summary>Bytes of a Logix STRING's length word.</summary>
private const int StringLengthBytes = 4;
private readonly AbCipTagCreateParams _p;
private readonly DeviceSessionPool? _pool;
private readonly ushort _elements;
// Encoded in InitializeAsync, never in the constructor: a malformed name must surface from
// Initialize (inside the driver's / probe's try), not from IAbCipTagFactory.Create — as libplctag.
private int? _implicitBit;
private byte[]? _path;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
private byte[] _data = [];
private ushort? _typeCode;
private ushort? _structHandle;
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
private string? _pendingString;
private bool _disposed;
/// <summary>Production constructor; <see cref="InitializeAsync"/> leases the connection from <paramref name="pool"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <param name="pool">The connection pool (the driver passes <see cref="DeviceSessionPool.Shared"/>).</param>
internal NativeAbCipTagRuntime(AbCipTagCreateParams p, DeviceSessionPool pool)
: this(p)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
}
private NativeAbCipTagRuntime(AbCipTagCreateParams p)
{
_p = p ?? throw new ArgumentNullException(nameof(p));
_elements = (ushort)Math.Clamp(p.ElementCount, 1, ushort.MaxValue);
}
/// <summary>
/// Test seam: a runtime over <paramref name="channel"/> with a private scheduler and no pool
/// lease. Production code always goes through <see cref="NativeAbCipTagFactory"/>.
/// </summary>
/// <param name="channel">The channel to send on.</param>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The runtime.</returns>
internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)
{
ArgumentNullException.ThrowIfNull(channel);
var rt = new NativeAbCipTagRuntime(p);
rt._privateScheduler = new RequestScheduler(channel, p.AllowPacking);
rt._client = new LogixTagClient(rt._privateScheduler, channel);
return rt;
}
/// <summary>The CIP type code of the last successful read, if any.</summary>
internal ushort? TypeCode => _typeCode;
/// <summary>The structure handle of the last successful read, if the controller returned one.</summary>
internal ushort? StructHandle => _structHandle;
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
internal int? ImplicitBit => _implicitBit;
/// <inheritdoc />
/// <remarks>
/// Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
/// <see cref="AbStatus.NotFound"/>.
/// </remarks>
public async Task InitializeAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_path is null)
{
// Before any lease: a malformed name costs no connection.
var (name, bit) = CipPath.SplitBitSelector(_p.TagName);
var path = CipPath.EncodeSymbolic(name);
_implicitBit = bit;
_path = path;
}
if (_client is null)
{
var lease = _pool!.Acquire(NativeAbCipDevice.Key(_p), NativeAbCipDevice.Options(_p));
_lease = lease;
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
}
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ReadAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path,_elements, cancellationToken).ConfigureAwait(false);
_data = data;
_typeCode = type;
_structHandle = handle;
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
}
catch (AbTransportException ex)
{
// libplctag reports a failed operation by exception AND leaves the status readable.
_status = ex.Status;
throw;
}
}
/// <inheritdoc />
public async Task WriteAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
// A bit tag ("Flags.3") names one bit of its host word. Writing any encoded value to it would
// write the WHOLE host and wipe its other bits on the controller; BOOL bit writes are an RMW the
// driver performs on a parent-DINT runtime (WriteBitInDIntAsync), so anything reaching here is refused.
if (_implicitBit is int implicitBit)
throw new NotSupportedException(
$"Write to bit tag '{_p.TagName}' (bit {implicitBit}) is not supported here — bit writes go through AbCipDriver.WriteBitInDIntAsync.");
try
{
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
// (0x02A0 + handle); ab_server reports 0x00D0 with no handle. Learn it by reading first
// when it is not yet known (we run under the driver's per-runtime lock).
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
throw new AbTransportException(AbStatus.Unsupported,
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
.ConfigureAwait(false);
}
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
?? throw new AbTransportException(AbStatus.Unsupported,
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
if (_typeCode is { } returned && IsTypeMismatch(returned))
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': the controller's type 0x{returned:X4} does not match the authored {_p.AuthoredType}.");
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
if (width > 0 && width != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
if (type == LogixTypeCode.BoolArray)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException("WriteAsync called before EncodeValue.");
}
_status = AbStatus.Ok;
}
catch (AbTransportException ex)
{
_status = ex.Status;
throw;
}
}
/// <inheritdoc />
public int GetStatus() => (int)_status;
/// <inheritdoc />
public int GetSize() => _data.Length;
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
_status == AbStatus.TypeMismatch ? null : DecodeAt(type, offset, bitIndex);
private object? DecodeAt(AbCipDataType type, int offset, int? bitIndex)
{
// libplctag on a bit tag ("Tag.5"): every integer getter returns that bit, and GetBit ignores
// the index it is given in favour of the tag's own bit.
if (_implicitBit is int implicitBit && IsIntegerShaped(type))
return BoxBit(type, GetBit(implicitBit));
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
AbCipDataType.UInt => (int)BinaryPrimitives.ReadUInt16LittleEndian(Span(offset, 2)),
AbCipDataType.DInt or AbCipDataType.Dt => BinaryPrimitives.ReadInt32LittleEndian(Span(offset, 4)),
AbCipDataType.UDInt => BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, 4)),
AbCipDataType.LInt => BinaryPrimitives.ReadInt64LittleEndian(Span(offset, 8)),
AbCipDataType.ULInt => BinaryPrimitives.ReadUInt64LittleEndian(Span(offset, 8)),
AbCipDataType.Real => BinaryPrimitives.ReadSingleLittleEndian(Span(offset, 4)),
AbCipDataType.LReal => BinaryPrimitives.ReadDoubleLittleEndian(Span(offset, 8)),
AbCipDataType.String => DecodeString(offset),
_ => null,
};
}
/// <inheritdoc />
public object? DecodeArray(AbCipDataType type, int count)
{
if (_status == AbStatus.TypeMismatch) return null;
if (count < 1) count = 1;
// Same stride rule as libplctag's runtime (which never has ElementSize after a read).
var stride = LibplctagTagRuntime.ResolveArrayStride(null, _data.Length, count, type);
return type switch
{
// A Logix BOOL array is bit-packed: element i is bit i of the buffer.
AbCipDataType.Bool => BuildBoolArray(count),
AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt
or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray<int>(count, stride, type),
AbCipDataType.UDInt => BuildArray<uint>(count, stride, type),
AbCipDataType.LInt => BuildArray<long>(count, stride, type),
AbCipDataType.ULInt => BuildArray<ulong>(count, stride, type),
AbCipDataType.Real => BuildArray<float>(count, stride, type),
AbCipDataType.LReal => BuildArray<double>(count, stride, type),
AbCipDataType.String => BuildArray<string>(count, stride, type),
_ => null,
};
}
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
_pendingString = null;
switch (type)
{
case AbCipDataType.Bool:
if (bitIndex is int)
{
// Same contract as LibplctagTagRuntime: BOOL-within-DINT writes are an RMW the driver
// performs on a parent-DINT runtime (AbCipDriver.WriteBitInDIntAsync).
throw new NotSupportedException(
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not NativeAbCipTagRuntime.");
}
_writeBuffer = [Convert.ToBoolean(value) ? (byte)1 : (byte)0];
break;
case AbCipDataType.SInt:
_writeBuffer = [(byte)Convert.ToSByte(value)];
break;
case AbCipDataType.USInt:
_writeBuffer = [Convert.ToByte(value)];
break;
case AbCipDataType.Int:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteInt16LittleEndian(_writeBuffer, Convert.ToInt16(value));
break;
case AbCipDataType.UInt:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(_writeBuffer, Convert.ToUInt16(value));
break;
case AbCipDataType.DInt:
case AbCipDataType.Dt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(_writeBuffer, Convert.ToInt32(value));
break;
case AbCipDataType.UDInt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(_writeBuffer, Convert.ToUInt32(value));
break;
case AbCipDataType.LInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteInt64LittleEndian(_writeBuffer, Convert.ToInt64(value));
break;
case AbCipDataType.ULInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(_writeBuffer, Convert.ToUInt64(value));
break;
case AbCipDataType.Real:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteSingleLittleEndian(_writeBuffer, Convert.ToSingle(value));
break;
case AbCipDataType.LReal:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteDoubleLittleEndian(_writeBuffer, Convert.ToDouble(value));
break;
case AbCipDataType.String:
// The payload needs the capacity learned from a read, which WriteAsync may have to do.
_pendingString = Convert.ToString(value) ?? string.Empty;
break;
case AbCipDataType.Structure:
throw new NotSupportedException(
"Whole-UDT writes are not supported — address individual member paths instead.");
default:
throw new NotSupportedException($"AbCipDataType {type} not writable.");
}
}
/// <summary>
/// Releases the connection lease, waiting at most 2 s so an immediate re-init does not open a
/// second session to the device while this one's Forward Close is still in flight (review
/// finding 22). Never disposes the shared scheduler; the pool does that on the last release.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
if (_lease is { } lease)
lease.DisposeAsync().AsTask().Wait(DisposeWait);
else if (_privateScheduler is { } scheduler)
scheduler.DisposeAsync().AsTask().Wait(DisposeWait);
}
catch (AggregateException)
{
// A faulted close is the pool's problem to log; Dispose must not throw.
}
_lease = null;
_privateScheduler = null;
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
private LogixTagClient Client()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _client ?? throw new InvalidOperationException("InitializeAsync has not been called.");
}
/// <summary>
/// True when the controller returned an atomic type other than the authored one. Exempt:
/// no authored type, String/Structure, a non-atomic return, a BOOL bit selector against an integer
/// host (<c>Flags.3</c> on a DINT), and an authored BOOL answered as 0xD3 (a packed BOOL-array
/// DWORD, which is how Logix returns a BOOL-array element), and same-width integers that differ only
/// in signedness or are the Micro800 bit-string type of that width (SINT/USINT/BYTE, INT/UINT/WORD,
/// DINT/UDINT/DWORD, LINT/ULINT/LWORD): libplctag reads those Good today and a transport cutover
/// must not turn them Bad. Different widths and integer↔real still mismatch.
/// </summary>
private bool IsTypeMismatch(ushort returned)
{
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
if (LogixTypeCode.ByteWidth(returned) < 0 || returned == expected) return false;
if (_p.AuthoredType == AbCipDataType.Bool)
{
if (returned == LogixTypeCode.BoolArray) return false;
if (_implicitBit is not null && IsIntegerCode(returned)) return false;
}
if (IntegerWidth(expected) is int width && IntegerWidth(returned) == width) return false;
return true;
}
/// <summary>Micro800 bit-string type codes (DWORD is <see cref="LogixTypeCode.BoolArray"/>, 0xD3).</summary>
private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4;
/// <summary>Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...).</summary>
private static int? IntegerWidth(ushort code) => code switch
{
LogixTypeCode.SInt or LogixTypeCode.USInt or ByteCode => 1,
LogixTypeCode.Int or LogixTypeCode.UInt or WordCode => 2,
LogixTypeCode.DInt or LogixTypeCode.UDInt or LogixTypeCode.BoolArray => 4,
LogixTypeCode.LInt or LogixTypeCode.ULInt or LWordCode => 8,
_ => null,
};
/// <summary>The Logix type code an authored type expects, or null when it is exempt from checking.</summary>
private static ushort? AtomicCode(AbCipDataType? type) => type switch
{
AbCipDataType.Bool => LogixTypeCode.Bool,
AbCipDataType.SInt => LogixTypeCode.SInt,
AbCipDataType.USInt => LogixTypeCode.USInt,
AbCipDataType.Int => LogixTypeCode.Int,
AbCipDataType.UInt => LogixTypeCode.UInt,
AbCipDataType.DInt or AbCipDataType.Dt => LogixTypeCode.DInt,
AbCipDataType.UDInt => LogixTypeCode.UDInt,
AbCipDataType.LInt => LogixTypeCode.LInt,
AbCipDataType.ULInt => LogixTypeCode.ULInt,
AbCipDataType.Real => LogixTypeCode.Real,
AbCipDataType.LReal => LogixTypeCode.LReal,
_ => null, // String, Structure
};
private static bool IsIntegerCode(ushort code) => code is LogixTypeCode.SInt or LogixTypeCode.Int
or LogixTypeCode.DInt or LogixTypeCode.LInt or LogixTypeCode.USInt or LogixTypeCode.UInt
or LogixTypeCode.UDInt or LogixTypeCode.ULInt;
private static bool IsIntegerShaped(AbCipDataType type) => type is AbCipDataType.Bool or AbCipDataType.SInt
or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt or AbCipDataType.DInt
or AbCipDataType.UDInt or AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.Dt;
private static object BoxBit(AbCipDataType type, bool bit) => type switch
{
AbCipDataType.Bool => bit,
AbCipDataType.UDInt => bit ? 1u : 0u,
AbCipDataType.LInt => bit ? 1L : 0L,
AbCipDataType.ULInt => bit ? 1UL : 0UL,
_ => bit ? 1 : 0,
};
/// <summary>Bit <paramref name="bit"/> of the little-endian buffer.</summary>
private bool GetBit(int bit)
{
if (bit < 0 || bit / 8 >= _data.Length)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Bit {bit} is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return (_data[bit / 8] & (1 << (bit % 8))) != 0;
}
/// <summary>The <paramref name="length"/> bytes at <paramref name="offset"/>; OutOfBounds like libplctag.</summary>
private ReadOnlySpan<byte> Span(int offset, int length)
{
if (offset < 0 || length > _data.Length - offset)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Offset {offset} + {length} byte(s) is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return _data.AsSpan(offset, length);
}
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
private string DecodeString(int offset)
{
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
var available = _data.Length - offset - StringLengthBytes;
var count = (int)Math.Min(len, (uint)available);
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
}
/// <summary>
/// <c>u32 len</c> + DATA padded to the capacity learned from the last read (data length − 4), so the
/// write carries the whole structure as the controller sized it.
/// </summary>
private byte[] EncodeStringPayload(string text)
{
var capacity = _data.Length - StringLengthBytes;
var bytes = Encoding.ASCII.GetBytes(text);
if (capacity < 0 || bytes.Length > capacity)
throw new AbTransportException(AbStatus.TooLarge,
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-byte capacity.");
var payload = new byte[StringLengthBytes + capacity];
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
bytes.CopyTo(payload, StringLengthBytes);
return payload;
}
private bool[] BuildBoolArray(int count)
{
var arr = new bool[count];
for (var i = 0; i < count; i++) arr[i] = GetBit(i);
return arr;
}
private T[] BuildArray<T>(int count, int stride, AbCipDataType type)
{
var arr = new T[count];
for (var i = 0; i < count; i++)
{
var decoded = DecodeAt(type, i * stride, null);
arr[i] = decoded is null ? default! : (T)decoded;
}
return arr;
}
}
@@ -0,0 +1,411 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task 12: the native tag runtime over <see cref="FakeCipChannel"/>. Reply buffers are hard-coded
/// bytes in the layout libplctag hands its getters (the Read Tag Fragmented reply data after the
/// type header), so each decode assertion is the value <see cref="LibplctagTagRuntime"/> would return
/// for the same buffer.
/// </summary>
public sealed class NativeAbCipTagRuntimeTests
{
private static AbCipTagCreateParams Params(
string tag, AbCipDataType? type, int elementCount = 1, bool isArray = false,
string plc = "controllogix", string cipPath = "1,0", bool connected = true) => new(
Gateway: "10.0.0.5",
Port: 44818,
CipPath: cipPath,
LibplctagPlcAttribute: plc,
TagName: tag,
Timeout: TimeSpan.FromSeconds(2),
AllowPacking: false,
ElementCount: elementCount,
IsArray: isArray,
AuthoredType: type,
UseConnectedMessaging: connected);
/// <summary>A Read Tag Fragmented (0xD2) success reply: type code (+ handle) then data.</summary>
private static FakeCipChannel Replying(string typeHex, string dataHex) => new()
{
Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + dataHex)),
};
private static async Task<NativeAbCipTagRuntime> ReadAsync(FakeCipChannel ch, AbCipTagCreateParams p)
{
var rt = NativeAbCipTagRuntime.ForTest(ch, p);
await rt.InitializeAsync(CancellationToken.None);
await rt.ReadAsync(CancellationToken.None);
return rt;
}
[Theory]
[InlineData(AbCipDataType.Bool, "C100", "01", true)]
[InlineData(AbCipDataType.SInt, "C200", "FE", -2)]
[InlineData(AbCipDataType.USInt, "C600", "FE", 254)]
[InlineData(AbCipDataType.Int, "C300", "FEFF", -2)]
[InlineData(AbCipDataType.UInt, "C700", "FEFF", 65534)]
[InlineData(AbCipDataType.DInt, "C400", "FEFFFFFF", -2)]
[InlineData(AbCipDataType.UDInt, "C800", "FEFFFFFF", 4294967294u)]
[InlineData(AbCipDataType.LInt, "C500", "FEFFFFFFFFFFFFFF", -2L)]
[InlineData(AbCipDataType.ULInt, "C900", "FEFFFFFFFFFFFFFF", 18446744073709551614UL)]
[InlineData(AbCipDataType.Real, "CA00", "0000803F", 1f)]
[InlineData(AbCipDataType.LReal, "CB00", "000000000000F03F", 1d)]
[InlineData(AbCipDataType.Dt, "C400", "40E20100", 123456)]
public async Task Each_atomic_type_decodes_exactly_as_libplctag_would(
AbCipDataType type, string typeHex, string dataHex, object expected)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", type));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.GetSize().ShouldBe(dataHex.Length / 2);
rt.DecodeValue(type, null).ShouldBe(expected);
}
[Fact]
public async Task A_Logix_STRING_decodes_LEN_then_ASCII_DATA()
{
// Structure 0x02A0 + handle 0x0FCE, then LEN=7 "HelloAB" in an 88-byte STRING.
var data = "07000000" + Convert.ToHexString(Encoding.ASCII.GetBytes("HelloAB")) + new string('0', 2 * 77);
using var rt = await ReadAsync(Replying("A002CE0F", data), Params("S", AbCipDataType.String));
rt.DecodeValue(AbCipDataType.String, null).ShouldBe("HelloAB");
rt.StructHandle.ShouldBe((ushort)0x0FCE);
}
[Fact]
public async Task A_STRING_whose_LEN_overruns_the_buffer_is_clamped_to_the_data()
{
using var rt = await ReadAsync(Replying("A002CE0F", "FF000000414243"), Params("S", AbCipDataType.String));
rt.DecodeValue(AbCipDataType.String, null).ShouldBe("ABC");
}
[Fact]
public async Task A_bit_index_reads_that_bit_of_the_little_endian_buffer()
{
// 0x00000100 → bit 8 set, bit 3 clear.
using var rt = await ReadAsync(Replying("C400", "00010000"), Params("Flags", null));
rt.DecodeValue(AbCipDataType.Bool, 8).ShouldBe(true);
rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(false);
}
[Fact]
public async Task A_BOOL_bit_selector_against_a_DINT_host_is_not_a_type_mismatch()
{
var ch = Replying("C400", "08000000");
using var rt = await ReadAsync(ch, Params("Flags.3", AbCipDataType.Bool));
rt.ImplicitBit.ShouldBe(3);
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(true);
// The bit selector is stripped from the encoded path: the request names only "Flags".
ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "9105466C61677300" + "0100" + "00000000"));
}
[Fact]
public async Task An_authored_BOOL_answered_as_a_packed_BOOL_array_DWORD_is_not_a_type_mismatch()
{
using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Bits[0]", AbCipDataType.Bool));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true);
}
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{
using var rt = await ReadAsync(Replying("CA00", "0000803F"), Params("Speed", AbCipDataType.DInt));
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull();
rt.DecodeValueAt(AbCipDataType.DInt, 0, null).ShouldBeNull();
rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBeNull();
}
[Fact]
public async Task A_structure_reply_is_never_a_type_mismatch()
{
using var rt = await ReadAsync(Replying("A002CE0F", "2A000000"), Params("Udt", AbCipDataType.DInt));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
}
[Fact]
public async Task Unknown_tag_fails_initialize_with_NotFound()
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) };
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
}
[Fact]
public async Task A_one_element_array_is_read_as_an_array()
{
var ch = Replying("C400", "2A000000");
using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 1, isArray: true));
rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBe(new[] { 42 });
}
[Fact]
public async Task An_array_read_requests_every_element_and_decodes_at_the_buffer_stride()
{
var ch = Replying("C400", "01000000" + "02000000" + "03000000");
using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 3, isArray: true));
rt.DecodeArray(AbCipDataType.DInt, 3).ShouldBe(new[] { 1, 2, 3 });
ch.Sent[0].ShouldBe(Convert.FromHexString("5203" + "9103417272" + "00" + "0300" + "00000000"));
}
[Fact]
public async Task A_BOOL_array_is_bit_packed()
{
using var rt = await ReadAsync(Replying("D300", "05000000"), Params("Bits", AbCipDataType.Bool, 3, true));
rt.DecodeArray(AbCipDataType.Bool, 3).ShouldBe(new[] { true, false, true });
}
[Fact]
public async Task A_user_string_array_uses_the_buffer_derived_stride()
{
// Two STR0016-style elements of 20 bytes (LEN + 16 DATA), not the static 88.
static string Element(string s) =>
Convert.ToHexString(LittleEndianU32(s.Length)) + Convert.ToHexString(Encoding.ASCII.GetBytes(s)).PadRight(32, '0');
using var rt = await ReadAsync(
Replying("A002AAAA", Element("ab") + Element("xyz")), Params("Strs", AbCipDataType.String, 2, true));
rt.DecodeArray(AbCipDataType.String, 2).ShouldBe(new[] { "ab", "xyz" });
}
private static byte[] LittleEndianU32(int v) => [(byte)v, (byte)(v >> 8), (byte)(v >> 16), (byte)(v >> 24)];
[Fact]
public async Task Decoding_past_the_buffer_is_OutOfBounds_as_libplctag_reports_it()
{
using var rt = await ReadAsync(Replying("C400", "2A000000"), Params("T", AbCipDataType.DInt));
Should.Throw<AbTransportException>(() => rt.DecodeValueAt(AbCipDataType.DInt, 2, null))
.Status.ShouldBe(AbStatus.OutOfBounds);
}
[Fact]
public async Task A_DINT_write_sends_Write_Tag_with_the_read_type()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.DInt, null, 42);
await rt.WriteAsync(CancellationToken.None);
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91015400" + "C400" + "0100" + "2A000000"));
}
[Fact]
public async Task A_STRING_write_sends_Struct_and_the_handle_learned_from_the_prior_read()
{
var ch = Replying("A002CE0F", "00000000" + new string('0', 2 * 84));
using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.String, null, "Hi");
await rt.WriteAsync(CancellationToken.None);
var expected = "4D02" + "91015300" + "A002" + "CE0F" + "0100"
+ "02000000" + "4869" + new string('0', 2 * 82);
ch.Sent[^1].ShouldBe(Convert.FromHexString(expected));
}
[Fact]
public async Task A_STRING_write_matches_the_libplctag_capture_when_the_server_reports_type_D0()
{
// Captures/logix-write-string.hex: ab_server reports a STRING as type 0x00D0 with no handle, and
// libplctag writes it back as D0 00, element count 1, LEN + DATA padded to the 88-byte read size.
var ch = Replying("D000", new string('0', 2 * 88));
using var rt = await ReadAsync(ch, Params("TestString", AbCipDataType.String));
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.String, null, "HelloAB");
await rt.WriteAsync(CancellationToken.None);
var captured = "4D06910A54657374537472696E67D00001000700000048656C6C6F4142" + new string('0', 2 * 77);
ch.Sent[^1].ShouldBe(Convert.FromHexString(captured));
}
[Fact]
public async Task A_STRING_longer_than_the_learned_capacity_is_TooLarge()
{
var ch = Replying("A002CE0F", "00000000" + "00000000");
using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String));
rt.EncodeValue(AbCipDataType.String, null, "toolong");
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TooLarge);
rt.GetStatus().ShouldBe((int)AbStatus.TooLarge);
}
[Fact]
public async Task A_write_whose_width_disagrees_with_the_controller_type_is_TypeMismatch_and_sends_nothing()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", null));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.Int, null, 1);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TypeMismatch);
ch.SentCount.ShouldBe(sentBefore);
}
[Fact]
public async Task A_Dt_write_under_DATE_type_0xCD_sends_four_bytes()
{
// 0xCD is outside LogixTypeCode's width table: no width check, the write goes out as today.
var ch = Replying("CD00", "00000000");
using var rt = await ReadAsync(ch, Params("D", AbCipDataType.Dt));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.Dt, null, 123456);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91014400" + "CD00" + "0100" + "40E20100"));
}
[Fact]
public async Task A_BOOL_write_over_a_packed_BOOL_array_DWORD_is_NotSupported_and_sends_nothing()
{
var ch = Replying("D300", "01000000");
using var rt = await ReadAsync(ch, Params("Bits[0]", AbCipDataType.Bool));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.Bool, null, false);
await Should.ThrowAsync<NotSupportedException>(() => rt.WriteAsync(CancellationToken.None));
ch.SentCount.ShouldBe(sentBefore);
}
[Theory]
[InlineData(AbCipDataType.DInt)]
[InlineData(AbCipDataType.Int)]
[InlineData(AbCipDataType.Bool)]
public async Task Any_write_to_a_bit_tag_is_NotSupported_and_sends_nothing(AbCipDataType authored)
{
// "Flags.3" authored as DInt/Int would otherwise write the value over the WHOLE host DINT.
var ch = Replying("C400", "08000000");
using var rt = await ReadAsync(ch, Params("Flags.3", authored));
var sentBefore = ch.SentCount;
rt.EncodeValue(authored, null, 1);
await Should.ThrowAsync<NotSupportedException>(() => rt.WriteAsync(CancellationToken.None));
ch.SentCount.ShouldBe(sentBefore);
}
[Fact]
public async Task A_write_after_a_same_width_type_mismatch_is_TypeMismatch_and_sends_nothing()
{
// REAL returned for a DINT-authored tag: same width, so only the read rule can catch it.
var ch = Replying("CA00", "0000803F");
using var rt = await ReadAsync(ch, Params("Speed", AbCipDataType.DInt));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.DInt, null, 42);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TypeMismatch);
ch.SentCount.ShouldBe(sentBefore);
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
}
[Theory]
[InlineData(AbCipDataType.DInt, "C800", "2A000000", 42)] // DINT ← UDINT
[InlineData(AbCipDataType.UDInt, "C400", "2A000000", 42u)] // UDINT ← DINT
[InlineData(AbCipDataType.Int, "C700", "2A00", 42)] // INT ← UINT
[InlineData(AbCipDataType.UInt, "C300", "2A00", 42)] // UINT ← INT
[InlineData(AbCipDataType.SInt, "C600", "2A", 42)] // SINT ← USINT
[InlineData(AbCipDataType.USInt, "C200", "2A", 42)] // USINT ← SINT
[InlineData(AbCipDataType.LInt, "C900", "2A00000000000000", 42L)] // LINT ← ULINT
[InlineData(AbCipDataType.ULInt, "C500", "2A00000000000000", 42UL)] // ULINT ← LINT
[InlineData(AbCipDataType.DInt, "D300", "2A000000", 42)] // DINT ← Micro800 DWORD
[InlineData(AbCipDataType.UDInt, "D300", "2A000000", 42u)] // UDINT ← Micro800 DWORD
[InlineData(AbCipDataType.Int, "D200", "2A00", 42)] // INT ← Micro800 WORD
[InlineData(AbCipDataType.USInt, "D100", "2A", 42)] // USINT ← Micro800 BYTE
[InlineData(AbCipDataType.LInt, "D400", "2A00000000000000", 42L)] // LINT ← Micro800 LWORD
public async Task Same_width_integer_pairs_are_not_a_type_mismatch(
AbCipDataType authored, string typeHex, string dataHex, object expected)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(authored, null).ShouldBe(expected);
}
[Theory]
[InlineData(AbCipDataType.Int, "C400", "2A000000")] // different width
[InlineData(AbCipDataType.Real, "D300", "2A000000")] // integer ↔ real, same width
[InlineData(AbCipDataType.UDInt, "CA00", "0000803F")] // real ↔ integer, same width
public async Task Different_widths_and_integer_real_pairs_still_mismatch(
AbCipDataType authored, string typeHex, string dataHex)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored));
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
}
[Fact]
public async Task A_malformed_tag_name_never_throws_from_Create_and_surfaces_on_Initialize_without_IO()
{
var ch = new FakeCipChannel();
var p = Params("Arr[abc]", AbCipDataType.DInt);
using var created = new NativeAbCipTagFactory().Create(p); // must not throw
using var rt = NativeAbCipTagRuntime.ForTest(ch, p);
await Should.ThrowAsync<Exception>(() => rt.InitializeAsync(CancellationToken.None));
ch.SentCount.ShouldBe(0);
}
[Fact]
public async Task A_refused_write_stores_the_status_and_rethrows()
{
var ch = Replying("C400", "00000000");
using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt));
ch.Respond = req => Task.FromResult(FakeCipChannel.Reply(req[0], 0x0F, []));
rt.EncodeValue(AbCipDataType.DInt, null, 1);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.NotAllowed);
rt.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
}
[Fact]
public void Encode_refuses_the_same_cases_as_libplctag()
{
using var rt = NativeAbCipTagRuntime.ForTest(new FakeCipChannel(), Params("T", AbCipDataType.Bool));
Should.Throw<NotSupportedException>(() => rt.EncodeValue(AbCipDataType.Bool, 3, true));
Should.Throw<NotSupportedException>(() => rt.EncodeValue(AbCipDataType.Structure, null, 1));
Should.Throw<NotSupportedException>(() => rt.EncodeValue((AbCipDataType)999, null, 1));
}
[Fact]
public void The_device_key_defaults_the_port_and_keeps_path_and_family()
{
NativeAbCipDevice.Key(Params("T", null) with { Port = 0 })
.ShouldBe(new DeviceKey("10.0.0.5", 44818, "1,0", "controllogix"));
NativeAbCipDevice.Key(Params("T", null) with { Port = 2222 }).Port.ShouldBe(2222);
}
[Fact]
public void Connection_options_carry_timeout_size_and_packing()
{
var o = NativeAbCipDevice.Options(Params("T", null) with { ConnectionSize = 504, AllowPacking = true });
o.Timeout.ShouldBe(TimeSpan.FromSeconds(2));
o.ConnectionSize.ShouldBe(504);
o.AllowPacking.ShouldBeTrue();
}
// Routing cross-check against Captures/README.md "Routing observed" (libplctag 2.6.0 on the wire):
// controllogix (path=1,0) → Large Forward Open, requests connected ⇒ Connected — matches.
// micro800 (no path) → Large Forward Open, path 20 02 24 01 ⇒ Connected — matches,
// even though the Micro800 profile says SupportsConnectedMessaging=false (ruling R12-1: the
// capture micro800-read-dint.hex beats the profile flag; the brief's UseConnectedMessaging rule
// alone would have chosen Direct, which is a MISMATCH with the capture).
// The UnconnectedSend / Direct rows below have no Logix-family capture; they follow the brief's rule.
[Theory]
[InlineData("controllogix", "1,0", true, CipRouting.Connected)]
[InlineData("compactlogix", "1,0", true, CipRouting.Connected)]
[InlineData("controllogix", "1,0", false, CipRouting.UnconnectedSend)]
[InlineData("controllogix", "", false, CipRouting.Direct)]
[InlineData("micro800", "", false, CipRouting.Connected)]
[InlineData("micro800", "", true, CipRouting.Connected)]
public void Routing_follows_the_family_profile_with_Micro800_always_connected(
string plc, string cipPath, bool connected, CipRouting expected) =>
NativeAbCipDevice.Options(Params("T", null, plc: plc, cipPath: cipPath, connected: connected))
.Routing.ShouldBe(expected);
[Fact]
public void The_factory_creates_native_runtimes()
{
using var rt = new NativeAbCipTagFactory().Create(Params("T", AbCipDataType.DInt));
rt.ShouldBeOfType<NativeAbCipTagRuntime>();
}
}
@@ -28,6 +28,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- Native transport test fake, source-linked so it stays internal (Task 12). -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />
</ItemGroup>
</Project>