feat(abcip): array writes encode count elements at the element width
U10 (user decision: implement array encode now): config, the AdminUI and discovery all advertised array tags Operate-writable, but both runtimes encoded a scalar - an int[] threw InvalidCastException (BadTypeMismatch AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent its whole last-read buffer with element 0 patched (stale elements 1..n-1). - AbCipArrayCodec encodes count elements little-endian at the element width, CONVERTING each element (ScadaBridge writes int[] to INT arrays; overflow is BadOutOfRange, unconvertible BadTypeMismatch, both before anything is sent). - The driver checks the shape before any runtime exists: not a list -> BadTypeMismatch, length != elementCount -> BadOutOfRange, BOOL/STRING/ structure element types -> BadNotSupported; and, before sending, a controller buffer that is not width x count -> BadTypeMismatch. Refusals warn once and write no health. - Native: ONE Write Tag of count elements with the controller's type code (the client fragments a large one); libplctag: the codec's bytes copied into the elem_count-sized buffer, refused TooSmall if it does not fit. - IAbCipTagRuntime.EncodeArray (default: NotSupportedException). Real-controller proof is a hardware userGate (see the lane report recipe). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a whole-array write (review 07 U10): <c>count</c> elements at the element type's wire width,
|
||||
/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both
|
||||
/// runtimes so the bytes on the wire are transport-independent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Element values CONVERT, they are not exact-matched.</b> ScadaBridge (and any OPC UA client that
|
||||
/// round-trips what it read) writes CLR lists such as <c>int[]</c> to an INT array — the read path itself
|
||||
/// publishes INT/SINT elements as <c>int</c> — so each element goes through the same <see cref="Convert"/> the
|
||||
/// scalar path uses. A value that does not fit is an <see cref="OverflowException"/> (BadOutOfRange) and one that
|
||||
/// cannot convert a <see cref="FormatException"/> / <see cref="InvalidCastException"/> (BadTypeMismatch), all
|
||||
/// thrown before any byte is sent.</para>
|
||||
/// <para>BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays
|
||||
/// (structures) and structure arrays are not encodable here; the driver refuses them <c>BadNotSupported</c>
|
||||
/// before a runtime exists.</para>
|
||||
/// </remarks>
|
||||
internal static class AbCipArrayCodec
|
||||
{
|
||||
/// <summary>Wire width of one element of an encodable array type, or <c>null</c> when the type is not encodable.</summary>
|
||||
/// <param name="type">The element type.</param>
|
||||
/// <returns>The element width in bytes, or <c>null</c>.</returns>
|
||||
public static int? ElementWidth(AbCipDataType type) => type switch
|
||||
{
|
||||
AbCipDataType.SInt or AbCipDataType.USInt => 1,
|
||||
AbCipDataType.Int or AbCipDataType.UInt => 2,
|
||||
AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The elements of an array write value, or <c>null</c> when the value is not a list (a scalar, a string, null).
|
||||
/// Any non-string <see cref="IEnumerable"/> is accepted — <c>int[]</c>, <c>short[]</c>, <c>List<object></c> …
|
||||
/// </summary>
|
||||
/// <param name="value">The client's write value.</param>
|
||||
/// <returns>The elements, or <c>null</c>.</returns>
|
||||
public static object?[]? Elements(object? value) => value switch
|
||||
{
|
||||
null or string => null,
|
||||
IEnumerable list => [.. list.Cast<object?>()],
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Encodes <paramref name="values"/> as <paramref name="type"/> elements.</summary>
|
||||
/// <param name="type">The element type (must have an <see cref="ElementWidth"/>).</param>
|
||||
/// <param name="values">The element values; their count is the element count.</param>
|
||||
/// <returns>The little-endian wire bytes, <c>values.Count × ElementWidth(type)</c> long.</returns>
|
||||
/// <exception cref="NotSupportedException">The type is not encodable.</exception>
|
||||
public static byte[] Encode(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
var width = ElementWidth(type)
|
||||
?? throw new NotSupportedException($"Array writes of {type} elements are not supported.");
|
||||
var buffer = new byte[values.Count * width];
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var v = values[i];
|
||||
var span = buffer.AsSpan(i * width, width);
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break;
|
||||
case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break;
|
||||
case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break;
|
||||
case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break;
|
||||
case AbCipDataType.DInt:
|
||||
case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break;
|
||||
case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break;
|
||||
case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break;
|
||||
case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break;
|
||||
case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break;
|
||||
case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break;
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1376,6 +1376,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
string? rmwParent = null;
|
||||
// U10: an array tag writes the WHOLE array. Shape is checked before any runtime exists, so a refused
|
||||
// write creates, reads and sends nothing; a refusal is not a fault — no health change.
|
||||
object?[]? arrayElements = null;
|
||||
if (IsArrayTag(def))
|
||||
{
|
||||
var count = Math.Max(1, def.ElementCount);
|
||||
uint? shapeRefusal = null;
|
||||
string reason = "";
|
||||
if (AbCipArrayCodec.ElementWidth(def.DataType) is null)
|
||||
{
|
||||
shapeRefusal = AbCipStatusMapper.BadNotSupported;
|
||||
reason = $"{def.DataType} arrays cannot be written element-wise (BOOL arrays are bit-packed DWORDs, STRING/structure elements are structures)";
|
||||
}
|
||||
else if (AbCipArrayCodec.Elements(w.Value) is not { } elements)
|
||||
{
|
||||
shapeRefusal = AbCipStatusMapper.BadTypeMismatch;
|
||||
reason = $"the value is not an array ({w.Value?.GetType().Name ?? "null"})";
|
||||
}
|
||||
else if (elements.Length != count)
|
||||
{
|
||||
shapeRefusal = AbCipStatusMapper.BadOutOfRange;
|
||||
reason = $"{elements.Length} element(s) written to a {count}-element array";
|
||||
}
|
||||
else
|
||||
{
|
||||
arrayElements = elements;
|
||||
}
|
||||
if (shapeRefusal is { } refusedShape)
|
||||
{
|
||||
results[i] = new WriteResult(refusedShape);
|
||||
WarnWriteRefusedOnce(def.Name,
|
||||
"AbCip array write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, reason);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
@@ -1413,10 +1450,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// BOOL-array DWORD wrote all 32 bits. Discovered UDT members are where a mis-authored type
|
||||
// is most likely (browse-commit fills dataType from the template, an operator may retype).
|
||||
size = runtime.GetSize();
|
||||
refusal = WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
|
||||
refusal = arrayElements is not null
|
||||
? ArrayWriteWidthRefusal(def, size)
|
||||
: WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
|
||||
if (refusal is null)
|
||||
{
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
if (arrayElements is not null) runtime.EncodeArray(def.DataType, arrayElements);
|
||||
else runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
}
|
||||
@@ -1436,7 +1476,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[i] = new WriteResult(refused);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused before sending: the controller holds {Size} byte(s) there but the authored {DataType} encodes {Width} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, size, def.DataType, ExpectedScalarWriteWidth(def.DataType));
|
||||
w.FullReference, def.DeviceHostAddress, size, def.DataType,
|
||||
arrayElements is not null
|
||||
? AbCipArrayCodec.ElementWidth(def.DataType) * arrayElements.Length
|
||||
: ExpectedScalarWriteWidth(def.DataType));
|
||||
}
|
||||
else if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
@@ -2055,6 +2098,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The refusal for a whole-array write whose authored shape does not fit what the controller holds: the last
|
||||
/// read returned <paramref name="size"/> bytes, which must be the element width × the element count. Unknown
|
||||
/// size (<c><= 0</c>) is not judged here (the native runtime re-checks against the controller's type code).
|
||||
/// </summary>
|
||||
/// <param name="def">The array tag being written.</param>
|
||||
/// <param name="size">The runtime's read-buffer size in bytes.</param>
|
||||
/// <returns><c>BadTypeMismatch</c>, or <c>null</c> to write.</returns>
|
||||
internal static uint? ArrayWriteWidthRefusal(AbCipTagDefinition def, int size)
|
||||
{
|
||||
if (size <= 0 || AbCipArrayCodec.ElementWidth(def.DataType) is not { } width) return null;
|
||||
return size == width * Math.Max(1, def.ElementCount) ? null : AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
|
||||
/// ATOMIC leaf — under the folder produced by <paramref name="folder"/>. Mirrors the
|
||||
|
||||
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Bit index for BOOL-within-DINT insertion, or null.</param>
|
||||
/// <param name="value">Value to encode.</param>
|
||||
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Encode a WHOLE-array write of <paramref name="values"/> (<c>values.Count</c> elements of
|
||||
/// <paramref name="type"/>, at the element width — see <see cref="AbCipArrayCodec"/>) into the local buffer.
|
||||
/// Callers pair this with <see cref="WriteAsync"/>. A runtime that cannot write arrays throws
|
||||
/// <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="type">The array element type.</param>
|
||||
/// <param name="values">The element values; already length-checked against the tag's element count.</param>
|
||||
void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
|
||||
throw new NotSupportedException($"{GetType().Name} does not support array writes.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -235,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_ => 4,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The codec's bytes are copied into the buffer the last read sized (<c>elem_count</c> elements), so the write
|
||||
/// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
|
||||
/// (<see cref="AbStatus.TooSmall"/>) before anything is changed.
|
||||
/// </remarks>
|
||||
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values) =>
|
||||
LibplctagStatusTranslation.WrapSync(() =>
|
||||
{
|
||||
var bytes = AbCipArrayCodec.Encode(type, values);
|
||||
var size = _tag.GetSize();
|
||||
if (size < bytes.Length)
|
||||
throw new AbTransportException(AbStatus.TooSmall,
|
||||
$"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
|
||||
for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
|
||||
});
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
|
||||
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
|
||||
|
||||
@@ -55,6 +55,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
private AbStatus _status = AbStatus.Pending;
|
||||
|
||||
private byte[]? _writeBuffer;
|
||||
private ushort _writeElements = 1;
|
||||
private string? _pendingString;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -233,17 +234,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
// 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)
|
||||
if (width > 0 && width * _writeElements != 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)
|
||||
if (type == LogixTypeCode.BoolArray && _writeElements == 1)
|
||||
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.");
|
||||
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(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);
|
||||
// U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
|
||||
await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -327,10 +329,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
_writeBuffer = null;
|
||||
_pendingString = null;
|
||||
var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
|
||||
_writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
|
||||
_writeBuffer = buffer;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
_writeBuffer = null;
|
||||
_writeElements = 1;
|
||||
_pendingString = null;
|
||||
switch (type)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes
|
||||
/// Operate-writable, but both runtimes encoded a SCALAR: an <c>int[]</c> threw InvalidCastException (BadTypeMismatch
|
||||
/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only
|
||||
/// element 0 patched. The driver now encodes <c>count</c> elements at the element width, refuses a length mismatch
|
||||
/// with <c>BadOutOfRange</c>, and converts element values (ScadaBridge writes <c>int[]</c> to INT arrays).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayWriteTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(
|
||||
AbCipDataType type, int count, int size = -1)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "abcip-array-write", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- the codec ----
|
||||
|
||||
/// <summary>Each element is converted and laid out little-endian at the element width.</summary>
|
||||
[Fact]
|
||||
public void The_codec_converts_elements_and_lays_them_out_at_the_element_width()
|
||||
{
|
||||
AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040"));
|
||||
AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF"));
|
||||
Should.Throw<OverflowException>(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000]));
|
||||
Should.Throw<NotSupportedException>(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true]));
|
||||
}
|
||||
|
||||
// ---- the driver ----
|
||||
|
||||
/// <summary>ScadaBridge's wire shape: an <c>int[]</c> to an INT[3] encodes 3 two-byte elements and writes once, Good.</summary>
|
||||
[Fact]
|
||||
public async Task An_int_array_to_an_INT_array_encodes_every_element()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var tag = factory.Tags["Arr"];
|
||||
tag.EncodedArrayType.ShouldBe(AbCipDataType.Int);
|
||||
tag.EncodedArrayCount.ShouldBe(3);
|
||||
tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300"));
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A list of a different length is BadOutOfRange; nothing is created, encoded or written.</summary>
|
||||
/// <param name="length">The client's element count.</param>
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(4)]
|
||||
public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length)
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only.</summary>
|
||||
[Fact]
|
||||
public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3);
|
||||
|
||||
var r = await drv.WriteAsync(
|
||||
[new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
factory.Tags.ShouldNotContainKey("Arr");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>An element that does not fit is BadOutOfRange with no health write and nothing sent.</summary>
|
||||
[Fact]
|
||||
public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
|
||||
factory.Tags["Arr"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>BOOL / STRING arrays are refused BadNotSupported before any runtime exists.</summary>
|
||||
/// <param name="type">The array element type.</param>
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Bool)]
|
||||
[InlineData(AbCipDataType.String)]
|
||||
public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type)
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(type, 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
factory.Tags.ShouldNotContainKey("Arr");
|
||||
}
|
||||
|
||||
/// <summary>The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent.</summary>
|
||||
[Fact]
|
||||
public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
factory.Tags["Arr"].WriteCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end over the REAL native runtime: an <c>int[]</c> to an INT[3] goes out as ONE Write Tag of type
|
||||
/// 0xC3 with element count 3 and the three 2-byte elements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_array_write_is_one_write_of_count_elements()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(req[0] switch
|
||||
{
|
||||
0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0
|
||||
0x4D => [0xCD, 0x00, 0x00, 0x00],
|
||||
_ => target.ServiceReply(req),
|
||||
});
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool()));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var write = target.ConnectedCips.Single(c => c[0] == 0x4D);
|
||||
var typeAt = 2 + write[1] * 2;
|
||||
write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
||||
/// <param name="value">The value to encode.</param>
|
||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
/// <summary>The element type of the last <see cref="EncodeArray"/>, if any.</summary>
|
||||
public AbCipDataType? EncodedArrayType { get; private set; }
|
||||
|
||||
/// <summary>The bytes the last <see cref="EncodeArray"/> produced (the shared codec, so conversions throw as in production).</summary>
|
||||
public byte[]? EncodedArrayBytes { get; private set; }
|
||||
|
||||
/// <summary>The element count of the last <see cref="EncodeArray"/>.</summary>
|
||||
public int EncodedArrayCount { get; private set; }
|
||||
|
||||
/// <summary>Encodes an array write through the production codec and records it.</summary>
|
||||
/// <param name="type">The element type.</param>
|
||||
/// <param name="values">The element values.</param>
|
||||
public virtual void EncodeArray(AbCipDataType type, IReadOnlyList<object?> values)
|
||||
{
|
||||
EncodedArrayBytes = AbCipArrayCodec.Encode(type, values);
|
||||
EncodedArrayType = type;
|
||||
EncodedArrayCount = values.Count;
|
||||
}
|
||||
|
||||
/// <summary>Marks the tag as disposed.</summary>
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user