feat(s7): 1-D array block read + decode loop + IsArray discovery
This commit is contained in:
@@ -104,13 +104,22 @@ public sealed class S7ProbeOptions
|
|||||||
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
||||||
/// coils that drive edge-triggered routines in the PLC program.
|
/// coils that drive edge-triggered routines in the PLC program.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="ArrayCount">
|
||||||
|
/// Element count when the tag is a 1-D array; <c>null</c> (or <c><= 1</c>) for a scalar.
|
||||||
|
/// For an equipment tag this is threaded from the <c>TagConfig</c> JSON's <c>arrayLength</c>
|
||||||
|
/// (honoured only when <c>isArray</c> is true) by <see cref="S7EquipmentTagParser"/>. When
|
||||||
|
/// set, the driver issues a single contiguous block read of
|
||||||
|
/// <c>ArrayCount × element-bytes</c> from the tag's start address and decodes each element
|
||||||
|
/// into an element-typed CLR array (<c>short[]</c> / <c>int[]</c> / <c>float[]</c> / etc.).
|
||||||
|
/// </param>
|
||||||
public sealed record S7TagDefinition(
|
public sealed record S7TagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string Address,
|
string Address,
|
||||||
S7DataType DataType,
|
S7DataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
int StringLength = 254,
|
int StringLength = 254,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? ArrayCount = null);
|
||||||
|
|
||||||
public enum S7DataType
|
public enum S7DataType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,12 +36,18 @@ public static class S7EquipmentTagParser
|
|||||||
// Range-guard rather than truncate: an S7 string can't exceed 254 chars, and a
|
// Range-guard rather than truncate: an S7 string can't exceed 254 chars, and a
|
||||||
// negative length is meaningless — reject so a malformed blob can't slip through.
|
// negative length is meaningless — reject so a malformed blob can't slip through.
|
||||||
if (stringLength < 0 || stringLength > MaxStringLength) return false;
|
if (stringLength < 0 || stringLength > MaxStringLength) return false;
|
||||||
|
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
|
||||||
|
// honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror
|
||||||
|
// that here so the driver's transient def agrees byte-for-byte with the materialised
|
||||||
|
// OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
|
||||||
|
var arrayCount = ReadArrayCount(root);
|
||||||
def = new S7TagDefinition(
|
def = new S7TagDefinition(
|
||||||
Name: reference,
|
Name: reference,
|
||||||
Address: address,
|
Address: address,
|
||||||
DataType: dataType,
|
DataType: dataType,
|
||||||
Writable: true, // node-level authz governs writes
|
Writable: true, // node-level authz governs writes
|
||||||
StringLength: stringLength == 0 ? MaxStringLength : stringLength);
|
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
|
||||||
|
ArrayCount: arrayCount);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (JsonException) { return false; }
|
catch (JsonException) { return false; }
|
||||||
@@ -56,4 +62,26 @@ public static class S7EquipmentTagParser
|
|||||||
private static int ReadInt(JsonElement o, string name)
|
private static int ReadInt(JsonElement o, string name)
|
||||||
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
|
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
|
||||||
&& e.TryGetInt32(out var v) ? v : 0;
|
&& e.TryGetInt32(out var v) ? v : 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the optional 1-D array element count from the TagConfig blob. Returns the
|
||||||
|
/// <c>arrayLength</c> int ONLY when <c>isArray</c> is <c>true</c> AND <c>arrayLength</c>
|
||||||
|
/// is a positive JSON integer; <c>null</c> otherwise (scalar). Mirrors the byte-parity
|
||||||
|
/// contract of <c>DeploymentArtifact.ExtractTagArray</c> on the sink side.
|
||||||
|
/// </summary>
|
||||||
|
private static int? ReadArrayCount(JsonElement root)
|
||||||
|
{
|
||||||
|
var isArray = root.TryGetProperty("isArray", out var aEl)
|
||||||
|
&& (aEl.ValueKind == JsonValueKind.True || aEl.ValueKind == JsonValueKind.False)
|
||||||
|
&& aEl.GetBoolean();
|
||||||
|
if (!isArray) return null;
|
||||||
|
if (root.TryGetProperty("arrayLength", out var lEl)
|
||||||
|
&& lEl.ValueKind == JsonValueKind.Number
|
||||||
|
&& lEl.TryGetInt32(out var len)
|
||||||
|
&& len > 0)
|
||||||
|
{
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
|
using S7NetDataType = global::S7.Net.DataType;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
@@ -440,6 +441,14 @@ public sealed class S7Driver
|
|||||||
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
|
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
|
||||||
? parsed
|
? parsed
|
||||||
: S7AddressParser.Parse(tag.Address);
|
: S7AddressParser.Parse(tag.Address);
|
||||||
|
|
||||||
|
// Array path: a tag with a declared count > 1 reads a CONTIGUOUS block of
|
||||||
|
// count × element-bytes in a SINGLE round-trip (Plc.ReadBytesAsync), then decodes each
|
||||||
|
// element from its big-endian slice into an element-typed CLR array. The scalar path
|
||||||
|
// (count null / <= 1) is left byte-for-byte unchanged below.
|
||||||
|
if (tag.ArrayCount is > 1)
|
||||||
|
return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||||
@@ -451,6 +460,154 @@ public sealed class S7Driver
|
|||||||
return ReinterpretRawValue(tag, addr, raw);
|
return ReinterpretRawValue(tag, addr, raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a 1-D array tag as ONE contiguous block (<c>count × element-bytes</c>) via
|
||||||
|
/// S7.Net's buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c>
|
||||||
|
/// — a single PLC round-trip, NOT <c>N</c> string reads — then hands the raw byte block
|
||||||
|
/// to the pure <see cref="DecodeArrayBlock"/> decode loop. Timer/Counter areas are
|
||||||
|
/// already rejected at init, so only DB/M/I/Q reach here.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<object> ReadArrayAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = tag.ArrayCount!.Value;
|
||||||
|
var elementBytes = ElementByteSize(addr.Size);
|
||||||
|
var totalBytes = count * elementBytes;
|
||||||
|
|
||||||
|
// ReadBytesAsync addresses by (area, db, startByteOffset, byteCount). The parser already
|
||||||
|
// normalised the start to a BYTE offset (ByteOffset) for DB/M/I/Q; a Bit array starts at
|
||||||
|
// its byte and consumes one byte per element (byte-granular contiguous bit access). S7.Net
|
||||||
|
// transparently splits a > PDU-sized block into multiple wire requests, so the driver
|
||||||
|
// doesn't have to chunk.
|
||||||
|
var area = ToS7NetArea(addr.Area);
|
||||||
|
var block = await plc.ReadBytesAsync(area, addr.DbNumber, addr.ByteOffset, totalBytes, ct)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
|
||||||
|
|
||||||
|
return DecodeArrayBlock(tag, addr, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Width in bytes of one array element for the given access size. Bit elements are
|
||||||
|
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
|
||||||
|
/// <param name="size">The parsed access width.</param>
|
||||||
|
/// <returns>Element byte size: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
|
||||||
|
internal static int ElementByteSize(S7Size size) => size switch
|
||||||
|
{
|
||||||
|
S7Size.Bit => 1,
|
||||||
|
S7Size.Byte => 1,
|
||||||
|
S7Size.Word => 2,
|
||||||
|
S7Size.DWord => 4,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> for the
|
||||||
|
/// buffer-based block read. Timer/Counter are rejected at init so they never reach the
|
||||||
|
/// array path.
|
||||||
|
/// </summary>
|
||||||
|
private static S7NetDataType ToS7NetArea(S7Area area) => area switch
|
||||||
|
{
|
||||||
|
S7Area.DataBlock => S7NetDataType.DataBlock,
|
||||||
|
S7Area.Memory => S7NetDataType.Memory,
|
||||||
|
S7Area.Input => S7NetDataType.Input,
|
||||||
|
S7Area.Output => S7NetDataType.Output,
|
||||||
|
_ => throw new NotSupportedException(
|
||||||
|
$"S7 area {area} is not supported for array block reads (Timer/Counter are rejected at init)"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR
|
||||||
|
/// array (<c>short[]</c> / <c>ushort[]</c> / <c>int[]</c> / <c>uint[]</c> / <c>float[]</c>
|
||||||
|
/// / <c>byte[]</c> / <c>bool[]</c>), boxed as <see cref="object"/>. No network I/O —
|
||||||
|
/// factored out of <see cref="ReadArrayAsync"/> so the block-decode is unit-testable
|
||||||
|
/// against a known byte block without a live PLC (S7.Net ships no in-process fake).
|
||||||
|
/// Each element is read from its <c>i × element-bytes</c> slice using S7 big-endian byte
|
||||||
|
/// order, identical to the per-element semantics of <see cref="ReinterpretRawValue"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tag">Tag definition carrying the element <see cref="S7DataType"/> and array count.</param>
|
||||||
|
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
|
||||||
|
/// <param name="block">Raw contiguous byte block read from the PLC (length == count × element-bytes).</param>
|
||||||
|
/// <returns>An element-typed CLR array boxed as <see cref="object"/>.</returns>
|
||||||
|
internal static object DecodeArrayBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
|
||||||
|
{
|
||||||
|
var count = tag.ArrayCount is > 1 ? tag.ArrayCount.Value : 1;
|
||||||
|
var elementBytes = ElementByteSize(addr.Size);
|
||||||
|
|
||||||
|
switch (tag.DataType, addr.Size)
|
||||||
|
{
|
||||||
|
case (S7DataType.Bool, S7Size.Bit):
|
||||||
|
{
|
||||||
|
var a = new bool[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = (block[i] & 0x01) != 0;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.Byte, S7Size.Byte):
|
||||||
|
{
|
||||||
|
var a = new byte[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = block[i];
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.UInt16, S7Size.Word):
|
||||||
|
{
|
||||||
|
var a = new ushort[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = ReadBeUInt16(block, i * elementBytes);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.Int16, S7Size.Word):
|
||||||
|
{
|
||||||
|
var a = new short[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = unchecked((short)ReadBeUInt16(block, i * elementBytes));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.UInt32, S7Size.DWord):
|
||||||
|
{
|
||||||
|
var a = new uint[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = ReadBeUInt32(block, i * elementBytes);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.Int32, S7Size.DWord):
|
||||||
|
{
|
||||||
|
var a = new int[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = unchecked((int)ReadBeUInt32(block, i * elementBytes));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case (S7DataType.Float32, S7Size.DWord):
|
||||||
|
{
|
||||||
|
var a = new float[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
a[i] = BitConverter.UInt32BitsToSingle(ReadBeUInt32(block, i * elementBytes));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
case (S7DataType.Int64, _):
|
||||||
|
case (S7DataType.UInt64, _):
|
||||||
|
case (S7DataType.Float64, _):
|
||||||
|
case (S7DataType.String, _):
|
||||||
|
case (S7DataType.DateTime, _):
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"S7 array reads of {tag.DataType} land in a follow-up PR");
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new System.IO.InvalidDataException(
|
||||||
|
$"S7 array Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
|
||||||
|
$"'{tag.Address}' parsed as Size={addr.Size}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a big-endian 16-bit word from <paramref name="block"/> at <paramref name="offset"/>.</summary>
|
||||||
|
private static ushort ReadBeUInt16(byte[] block, int offset) =>
|
||||||
|
(ushort)((block[offset] << 8) | block[offset + 1]);
|
||||||
|
|
||||||
|
/// <summary>Reads a big-endian 32-bit dword from <paramref name="block"/> at <paramref name="offset"/>.</summary>
|
||||||
|
private static uint ReadBeUInt32(byte[] block, int offset) =>
|
||||||
|
((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
|
||||||
|
| ((uint)block[offset + 2] << 8) | block[offset + 3];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
|
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
|
||||||
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
|
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the
|
||||||
@@ -636,11 +793,15 @@ public sealed class S7Driver
|
|||||||
var folder = builder.Folder("S7", "S7");
|
var folder = builder.Folder("S7", "S7");
|
||||||
foreach (var t in _options.Tags)
|
foreach (var t in _options.Tags)
|
||||||
{
|
{
|
||||||
|
// A tag carrying an array count (> 1) surfaces as a 1-D OPC UA array node; a missing
|
||||||
|
// count or a count of 1 stays scalar (count == 1 array adds no information over a
|
||||||
|
// scalar and would force every read down the slower block path).
|
||||||
|
var isArray = t.ArrayCount is > 1;
|
||||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||||
FullName: t.Name,
|
FullName: t.Name,
|
||||||
DriverDataType: MapDataType(t.DataType),
|
DriverDataType: MapDataType(t.DataType),
|
||||||
IsArray: false,
|
IsArray: isArray,
|
||||||
ArrayDim: null,
|
ArrayDim: isArray ? (uint)t.ArrayCount!.Value : null,
|
||||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the S7 1-D array support: the pure
|
||||||
|
/// <see cref="S7Driver.DecodeArrayBlock"/> decode loop (the half of the contiguous
|
||||||
|
/// block read that turns a raw S7 big-endian byte block into a typed CLR array — the
|
||||||
|
/// network I/O half, <c>Plc.ReadBytesAsync</c>, has no in-process fake so only the
|
||||||
|
/// decode is unit-proven), the <see cref="ITagDiscovery"/> <c>IsArray</c>/<c>ArrayDim</c>
|
||||||
|
/// flip, and the equipment-tag resolver threading <c>arrayLength</c> into the transient
|
||||||
|
/// tag-def's array count.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7ArrayReadTests
|
||||||
|
{
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static S7TagDefinition ArrTag(S7DataType dt, int count) =>
|
||||||
|
new("ArrTag", "DB1.DBW0", dt, ArrayCount: count);
|
||||||
|
|
||||||
|
private static S7ParsedAddress Addr(S7Size size) =>
|
||||||
|
new(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: 0);
|
||||||
|
|
||||||
|
// S7 is big-endian: most-significant byte first.
|
||||||
|
private static byte[] BeWords(params ushort[] words)
|
||||||
|
{
|
||||||
|
var b = new byte[words.Length * 2];
|
||||||
|
for (var i = 0; i < words.Length; i++)
|
||||||
|
{
|
||||||
|
b[i * 2] = (byte)(words[i] >> 8);
|
||||||
|
b[i * 2 + 1] = (byte)(words[i] & 0xFF);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BeDwords(params uint[] dwords)
|
||||||
|
{
|
||||||
|
var b = new byte[dwords.Length * 4];
|
||||||
|
for (var i = 0; i < dwords.Length; i++)
|
||||||
|
{
|
||||||
|
b[i * 4] = (byte)(dwords[i] >> 24);
|
||||||
|
b[i * 4 + 1] = (byte)(dwords[i] >> 16);
|
||||||
|
b[i * 4 + 2] = (byte)(dwords[i] >> 8);
|
||||||
|
b[i * 4 + 3] = (byte)(dwords[i] & 0xFF);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DecodeArrayBlock — element-typed CLR arrays ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies an Int16 array decodes to a typed short[] with big-endian values.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_Int16_returns_short_array()
|
||||||
|
{
|
||||||
|
// 3 words: 1, -1 (0xFFFF), 32767 (0x7FFF).
|
||||||
|
var block = BeWords(0x0001, 0xFFFF, 0x7FFF);
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int16, 3), Addr(S7Size.Word), block);
|
||||||
|
|
||||||
|
var arr = result.ShouldBeOfType<short[]>();
|
||||||
|
arr.ShouldBe(new short[] { 1, -1, 32767 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a UInt16 array decodes to a typed ushort[].</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_UInt16_returns_ushort_array()
|
||||||
|
{
|
||||||
|
var block = BeWords(0, 1000, 65535);
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 3), Addr(S7Size.Word), block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<ushort[]>().ShouldBe(new ushort[] { 0, 1000, 65535 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies an Int32 array decodes to a typed int[] with big-endian dwords.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_Int32_returns_int_array()
|
||||||
|
{
|
||||||
|
var block = BeDwords(1u, 0xFFFF_FFFFu, 0x7FFF_FFFFu);
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Int32, 3), Addr(S7Size.DWord), block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<int[]>().ShouldBe(new[] { 1, -1, int.MaxValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a UInt32 array decodes to a typed uint[].</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_UInt32_returns_uint_array()
|
||||||
|
{
|
||||||
|
var block = BeDwords(0u, 70_000u, 0xFFFF_FFFFu);
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt32, 3), Addr(S7Size.DWord), block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<uint[]>().ShouldBe(new uint[] { 0, 70_000, 0xFFFF_FFFF });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a Float32 array decodes to a typed float[] from IEEE-754 big-endian dwords.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_Float32_returns_float_array()
|
||||||
|
{
|
||||||
|
var bits0 = BitConverter.SingleToUInt32Bits(1.5f);
|
||||||
|
var bits1 = BitConverter.SingleToUInt32Bits(-2.25f);
|
||||||
|
var bits2 = BitConverter.SingleToUInt32Bits(3.14f);
|
||||||
|
var block = BeDwords(bits0, bits1, bits2);
|
||||||
|
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Float32, 3), Addr(S7Size.DWord), block);
|
||||||
|
|
||||||
|
var arr = result.ShouldBeOfType<float[]>();
|
||||||
|
arr.Length.ShouldBe(3);
|
||||||
|
arr[0].ShouldBe(1.5f, tolerance: 1e-6f);
|
||||||
|
arr[1].ShouldBe(-2.25f, tolerance: 1e-6f);
|
||||||
|
arr[2].ShouldBe(3.14f, tolerance: 1e-6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a Byte array decodes to a typed byte[] (one element per byte).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_Byte_returns_byte_array()
|
||||||
|
{
|
||||||
|
var block = new byte[] { 0, 42, 200, 255 };
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.Byte, 4), Addr(S7Size.Byte), block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<byte[]>().ShouldBe(new byte[] { 0, 42, 200, 255 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a Bool array decodes from packed bits (one byte → low bit per element).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_Bool_returns_bool_array()
|
||||||
|
{
|
||||||
|
// Bit array: one byte per element, low bit carries the value (S7 contiguous bit access
|
||||||
|
// is byte-granular over the wire so each element occupies its own byte slot).
|
||||||
|
var block = new byte[] { 0x01, 0x00, 0x01 };
|
||||||
|
var result = S7Driver.DecodeArrayBlock(
|
||||||
|
new S7TagDefinition("B", "DB1.DBX0.0", S7DataType.Bool, ArrayCount: 3),
|
||||||
|
new S7ParsedAddress(S7Area.DataBlock, 1, S7Size.Bit, 0, 0),
|
||||||
|
block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<bool[]>().ShouldBe(new[] { true, false, true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the array length matches the tag's declared count.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecodeArrayBlock_length_matches_declared_count()
|
||||||
|
{
|
||||||
|
var block = BeWords(10, 20, 30, 40, 50);
|
||||||
|
var result = S7Driver.DecodeArrayBlock(ArrTag(S7DataType.UInt16, 5), Addr(S7Size.Word), block);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<ushort[]>().Length.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies unsupported element types throw NotSupportedException in the array path.</summary>
|
||||||
|
/// <param name="dt">The unsupported S7 data type.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(S7DataType.Int64)]
|
||||||
|
[InlineData(S7DataType.Float64)]
|
||||||
|
[InlineData(S7DataType.String)]
|
||||||
|
public void DecodeArrayBlock_unsupported_element_type_throws(S7DataType dt)
|
||||||
|
{
|
||||||
|
Should.Throw<NotSupportedException>(() =>
|
||||||
|
S7Driver.DecodeArrayBlock(ArrTag(dt, 2), Addr(S7Size.DWord), new byte[16]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ElementByteSize — block-read sizing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies element byte sizes used to size the contiguous block read.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(S7Size.Bit, 1)]
|
||||||
|
[InlineData(S7Size.Byte, 1)]
|
||||||
|
[InlineData(S7Size.Word, 2)]
|
||||||
|
[InlineData(S7Size.DWord, 4)]
|
||||||
|
public void ElementByteSize_matches_size_width(S7Size size, int expected)
|
||||||
|
=> S7Driver.ElementByteSize(size).ShouldBe(expected);
|
||||||
|
|
||||||
|
// ── Discovery — IsArray / ArrayDim flip ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attr)
|
||||||
|
{
|
||||||
|
Variables.Add((browseName, attr));
|
||||||
|
return new Handle();
|
||||||
|
}
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
||||||
|
private sealed class Handle : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => "stub";
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies an array tag is discovered with IsArray=true and ArrayDim=count.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_flips_IsArray_for_array_tag()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new("Scalar", "DB1.DBW0", S7DataType.Int16),
|
||||||
|
new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-arr-disco");
|
||||||
|
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var scalar = builder.Variables.Single(v => v.Name == "Scalar").Attr;
|
||||||
|
scalar.IsArray.ShouldBeFalse();
|
||||||
|
scalar.ArrayDim.ShouldBeNull();
|
||||||
|
|
||||||
|
var arr = builder.Variables.Single(v => v.Name == "Arr").Attr;
|
||||||
|
arr.IsArray.ShouldBeTrue();
|
||||||
|
arr.ArrayDim.ShouldBe(8u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a tag with ArrayCount of 1 is treated as a scalar (no array node).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_count_of_one_is_scalar()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-arr-one");
|
||||||
|
|
||||||
|
var builder = new RecordingBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var one = builder.Variables.Single().Attr;
|
||||||
|
one.IsArray.ShouldBeFalse("count<=1 is scalar");
|
||||||
|
one.ArrayDim.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment-tag resolver threads arrayLength → ArrayCount ───────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EquipmentTagParser_threads_array_length_into_ArrayCount()
|
||||||
|
{
|
||||||
|
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}""";
|
||||||
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||||
|
def!.ArrayCount.ShouldBe(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EquipmentTagParser_ignores_arrayLength_when_isArray_false()
|
||||||
|
{
|
||||||
|
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}""";
|
||||||
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||||
|
def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies a scalar equipment tag (no array keys) has a null ArrayCount.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void EquipmentTagParser_scalar_has_null_ArrayCount()
|
||||||
|
{
|
||||||
|
var json = """{"address":"DB1.DBW0","dataType":"Int16"}""";
|
||||||
|
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
|
||||||
|
def!.ArrayCount.ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user