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