feat(s7): 1-D array block read + decode loop + IsArray discovery

This commit is contained in:
Joseph Doherty
2026-06-16 21:54:50 -04:00
parent 8d3dc32148
commit a82c22c645
4 changed files with 468 additions and 4 deletions
@@ -104,13 +104,22 @@ public sealed class S7ProbeOptions
/// 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.
/// </param>
/// <param name="ArrayCount">
/// Element count when the tag is a 1-D array; <c>null</c> (or <c>&lt;= 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(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? ArrayCount = null);
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
// negative length is meaningless — reject so a malformed blob can't slip through.
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(
Name: reference,
Address: address,
DataType: dataType,
Writable: true, // node-level authz governs writes
StringLength: stringLength == 0 ? MaxStringLength : stringLength);
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
ArrayCount: arrayCount);
return true;
}
catch (JsonException) { return false; }
@@ -56,4 +62,26 @@ public static class S7EquipmentTagParser
private static int ReadInt(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number
&& 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.Abstractions;
using S7.Net;
using S7NetDataType = global::S7.Net.DataType;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -440,6 +441,14 @@ public sealed class S7Driver
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
? parsed
: 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
// 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
@@ -451,6 +460,154 @@ public sealed class S7Driver
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>
/// 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
@@ -636,11 +793,15 @@ public sealed class S7Driver
var folder = builder.Folder("S7", "S7");
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(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
IsArray: isArray,
ArrayDim: isArray ? (uint)t.ArrayCount!.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,