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 @@ + + + + + +