diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
index fd10e654..2ff21171 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs
@@ -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.
/// Review I-1 — the EXPLICIT array signal threaded through so a
/// 1-element array ( 1) is still read as an array.
+ /// The tag definition's authored type, for the native runtime's
+ /// returned-type check; null skips it.
/// The computed tag creation parameters.
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);
/// Disposes all runtime tag handles and clears the caches.
public void DisposeHandles()
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index 84bf5979..4f26a685 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
@@ -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
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index ed0c781d..91e3f378 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -119,6 +119,13 @@ public interface IAbCipTagFactory
/// element count for an isArray tag — including a 1-element array.
/// Review I-1 — the EXPLICIT array signal threaded from the tag definition so
/// a 1-element array ( 1) is still read as an array, not a scalar.
+/// 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
+/// TypeMismatch); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents)
+/// skips the check.
+/// From AbCipPlcFamilyProfile.SupportsConnectedMessaging at
+/// every construction site, so the family profile stays the single authority for the native
+/// transport's routing choice. libplctag ignores it.
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)
{
///
/// The value for libplctag's gateway attribute: host:port, or bare host when
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs
new file mode 100644
index 00000000..0a99ab2d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs
@@ -0,0 +1,55 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Maps 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.
+///
+internal static class NativeAbCipDevice
+{
+ /// The EtherNet/IP default TCP port, used when no usable port was configured.
+ internal const int DefaultPort = 44818;
+
+ /// The plc= attribute of the Micro800 family profile.
+ private const string Micro800Attribute = "micro800";
+
+ /// The pool key: one CIP connection per (host, port, cipPath, plcAttribute).
+ /// The tag creation parameters.
+ /// The device key.
+ public static DeviceKey Key(AbCipTagCreateParams p) =>
+ new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
+
+ /// The connection settings for the device addresses.
+ /// The tag creation parameters.
+ /// The connection options.
+ public static ConnectionOptions Options(AbCipTagCreateParams p) => new()
+ {
+ Routing = RoutingFor(p),
+ Timeout = p.Timeout,
+ ConnectionSize = p.ConnectionSize,
+ AllowPacking = p.AllowPacking,
+ };
+
+ ///
+ /// How requests reach the controller's message router.
+ ///
+ ///
+ /// Micro800 is always with an empty route, even though its
+ /// family profile says SupportsConnectedMessaging: false: libplctag opens a Large Forward
+ /// Open with connection path 20 02 24 01 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.
+ ///
+ /// The tag creation parameters.
+ /// The routing mode.
+ 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;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs
new file mode 100644
index 00000000..5c435bdc
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs
@@ -0,0 +1,15 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// for the native transport: every runtime leases its device's
+/// connection from , so all tags on one device share one CIP
+/// connection and one request scheduler. Stateless; safe to share across devices.
+///
+internal sealed class NativeAbCipTagFactory : IAbCipTagFactory
+{
+ ///
+ public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
+ new NativeAbCipTagRuntime(createParams, DeviceSessionPool.Shared);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
new file mode 100644
index 00000000..f445bd2d
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
@@ -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;
+
+///
+/// Native over the managed EtherNet/IP + CIP stack. Leases the
+/// device's shared connection from and reads/writes through a
+/// per-runtime over the lease's shared scheduler, so concurrent
+/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors
+/// byte for byte; unlike libplctag it also checks the CIP type
+/// code the controller returned against the authored type.
+///
+///
+/// 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.
+///
+internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
+{
+ /// Upper bound on waiting for the lease release (and its Forward Close) in .
+ private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
+
+ /// Bytes of a Logix STRING's length word.
+ 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;
+
+ /// Production constructor; leases the connection from .
+ /// The tag creation parameters.
+ /// The connection pool (the driver passes ).
+ 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);
+ }
+
+ ///
+ /// Test seam: a runtime over with a private scheduler and no pool
+ /// lease. Production code always goes through .
+ ///
+ /// The channel to send on.
+ /// The tag creation parameters.
+ /// The runtime.
+ 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;
+ }
+
+ /// The CIP type code of the last successful read, if any.
+ internal ushort? TypeCode => _typeCode;
+
+ /// The structure handle of the last successful read, if the controller returned one.
+ internal ushort? StructHandle => _structHandle;
+
+ /// The trailing bit selector split off the tag name (Tag.5), if any.
+ internal int? ImplicitBit => _implicitBit;
+
+ ///
+ ///
+ /// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an
+ /// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
+ /// .
+ ///
+ 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);
+ }
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ 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;
+ }
+ }
+
+ ///
+ public int GetStatus() => (int)_status;
+
+ ///
+ public int GetSize() => _data.Length;
+
+ ///
+ public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
+
+ ///
+ 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,
+ };
+ }
+
+ ///
+ 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(count, stride, type),
+ AbCipDataType.UDInt => BuildArray(count, stride, type),
+ AbCipDataType.LInt => BuildArray(count, stride, type),
+ AbCipDataType.ULInt => BuildArray(count, stride, type),
+ AbCipDataType.Real => BuildArray(count, stride, type),
+ AbCipDataType.LReal => BuildArray(count, stride, type),
+ AbCipDataType.String => BuildArray(count, stride, type),
+ _ => null,
+ };
+ }
+
+ ///
+ 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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.");
+ }
+
+ ///
+ /// 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 (Flags.3 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.
+ ///
+ 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;
+ }
+
+ /// Micro800 bit-string type codes (DWORD is , 0xD3).
+ private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4;
+
+ /// Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...).
+ 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,
+ };
+
+ /// The Logix type code an authored type expects, or null when it is exempt from checking.
+ 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,
+ };
+
+ /// Bit of the little-endian buffer.
+ 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;
+ }
+
+ /// The bytes at ; OutOfBounds like libplctag.
+ private ReadOnlySpan 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);
+ }
+
+ /// Logix STRING: u32 LEN then ASCII DATA, LEN clamped to what the buffer holds.
+ 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);
+ }
+
+ ///
+ /// u32 len + 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.
+ ///
+ 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(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;
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
new file mode 100644
index 00000000..2e6e6b5d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
@@ -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;
+
+///
+/// Task 12: the native tag runtime over . 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 would return
+/// for the same buffer.
+///
+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);
+
+ /// A Read Tag Fragmented (0xD2) success reply: type code (+ handle) then data.
+ private static FakeCipChannel Replying(string typeHex, string dataHex) => new()
+ {
+ Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + dataHex)),
+ };
+
+ private static async Task 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => rt.EncodeValue(AbCipDataType.Bool, 3, true));
+ Should.Throw(() => rt.EncodeValue(AbCipDataType.Structure, null, 1));
+ Should.Throw(() => 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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
index b35b6c83..1b4c8152 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj
@@ -28,6 +28,12 @@
+
+
+
+
+
+