Auto: s7-a4 — array tags (ValueRank=1)

- S7TagDefinition gets optional ElementCount; >1 marks the tag as a 1-D array.
- ReadOneAsync / WriteOneAsync: one byte-range Read/WriteBytesAsync covering
  N × elementBytes, sliced/packed client-side via the existing big-endian scalar
  codecs and S7DateTimeCodec.
- DiscoverAsync surfaces IsArray=true and ArrayDim=ElementCount → ValueRank=1.
- Init-time validation (now ahead of TCP open) caps ElementCount at 8000 and
  rejects unsupported element types: STRING/WSTRING/CHAR/WCHAR (variable-width)
  and BOOL (packed-bit layout) — both follow-ups.
- Supported element types: Byte, Int16/UInt16, Int32/UInt32, Int64/UInt64,
  Float32, Float64, Date, Time, TimeOfDay.

Closes #290
This commit is contained in:
Joseph Doherty
2026-04-25 16:49:02 -04:00
parent 676eebd5e4
commit ce98c2ada3
3 changed files with 399 additions and 15 deletions

View File

@@ -54,6 +54,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
private const uint StatusBadDeviceFailure = 0x80550000u;
/// <summary>
/// Hard upper bound on <see cref="S7TagDefinition.ElementCount"/>. The S7 PDU envelope
/// for negotiated default 240-byte and extended 960-byte payloads cannot fit a single
/// byte-range read larger than ~960 bytes, so a Float64 array of more than ~120
/// elements is already lossy. 8000 is an order-of-magnitude generous ceiling that still
/// rejects obvious config typos (e.g. ElementCount = 65535) at init time.
/// </summary>
internal const int MaxArrayElements = 8000;
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
@@ -85,6 +94,32 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
// Parse + validate every tag before opening the TCP socket so config bugs
// (bad address, oversized array, unsupported array element) surface as
// FormatException without waiting on a connect timeout. Per the v1 driver-config
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
_tagsByName.Clear();
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
if (t.ElementCount is int n && n > 1)
{
// Array sanity: cap at S7 PDU realistic limit, reject variable-width
// element types and BOOL (packed-bit layout) up-front so a config typo
// fails at init instead of surfacing as BadInternalError on every read.
if (n > MaxArrayElements)
throw new FormatException(
$"S7 tag '{t.Name}' ElementCount {n} exceeds S7 PDU realistic limit ({MaxArrayElements})");
if (!IsArrayElementSupported(t.DataType))
throw new FormatException(
$"S7 tag '{t.Name}' DataType {t.DataType} not supported as an array element " +
$"(variable-width string types and BOOL packed-bit arrays are a follow-up)");
}
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
}
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
@@ -98,18 +133,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
Plc = plc;
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
_tagsByName.Clear();
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
// Kick off the probe loop once the connection is up. Initial HostState stays
@@ -222,6 +245,26 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
{
var addr = _parsedByName[tag.Name];
// 1-D array path: one byte-range read covering N×elementBytes, sliced client-side.
// Init-time validation guarantees only fixed-width element types reach here.
if (tag.ElementCount is int n && n > 1)
{
var elemBytes = ArrayElementBytes(tag.DataType);
var totalBytes = checked(n * elemBytes);
if (addr.Size == S7Size.Bit)
throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; arrays require byte-addressing");
var arrBytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, totalBytes, ct)
.ConfigureAwait(false);
if (arrBytes is null || arrBytes.Length != totalBytes)
throw new System.IO.InvalidDataException(
$"S7.Net returned {arrBytes?.Length ?? 0} bytes for array '{tag.Address}' (n={n}), expected {totalBytes}");
return SliceArray(arrBytes, tag.DataType, n, elemBytes);
}
// String-shaped types (STRING/WSTRING/CHAR/WCHAR): S7.Net's string-keyed ReadAsync
// has no syntax for these, so the driver issues a raw byte read and decodes via
// S7StringCodec. Wire order is big-endian for the WSTRING/WCHAR UTF-16 payload.
@@ -421,6 +464,23 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
// 1-D array path: pack all N elements into a single buffer then push via WriteBytesAsync.
// Init-time validation guarantees only fixed-width element types reach here.
if (tag.ElementCount is int n && n > 1)
{
var addr = _parsedByName[tag.Name];
if (addr.Size == S7Size.Bit)
throw new InvalidOperationException(
$"S7 Write type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
$"parsed as bit-access; arrays require byte-addressing");
if (value is null)
throw new ArgumentNullException(nameof(value));
var elemBytes = ArrayElementBytes(tag.DataType);
var buf = PackArray(value, tag.DataType, n, elemBytes, tag.Name);
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
return;
}
// String-shaped types: encode via S7StringCodec then push via WriteBytesAsync. The
// codec rejects out-of-range lengths and non-ASCII for CHAR — we let the resulting
// ArgumentException bubble out so the WriteAsync caller maps it to BadInternalError.
@@ -531,11 +591,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
var folder = builder.Folder("S7", "S7");
foreach (var t in _options.Tags)
{
var isArr = t.ElementCount is int ec && ec > 1;
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
FullName: t.Name,
DriverDataType: MapDataType(t.DataType),
IsArray: false,
ArrayDim: null,
IsArray: isArr,
ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
@@ -544,6 +605,173 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
return Task.CompletedTask;
}
/// <summary>
/// True when <paramref name="t"/> can be used as an array element. Variable-width string
/// types and BOOL (packed-bit layout) are rejected — both need bespoke addressing
/// beyond a flat <c>N × elementBytes</c> byte-range read and ship as a follow-up.
/// </summary>
internal static bool IsArrayElementSupported(S7DataType t) => t is
S7DataType.Byte or
S7DataType.Int16 or S7DataType.UInt16 or
S7DataType.Int32 or S7DataType.UInt32 or
S7DataType.Int64 or S7DataType.UInt64 or
S7DataType.Float32 or S7DataType.Float64 or
S7DataType.Date or S7DataType.Time or S7DataType.TimeOfDay;
/// <summary>
/// On-wire bytes per array element for the supported fixed-width element types. DATE
/// is a 16-bit days-since-1990 counter, TIME and TOD are 32-bit ms counters.
/// </summary>
internal static int ArrayElementBytes(S7DataType t) => t switch
{
S7DataType.Byte => 1,
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Date => 2,
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
or S7DataType.Time or S7DataType.TimeOfDay => 4,
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
_ => throw new InvalidOperationException($"S7 array element bytes undefined for {t}"),
};
/// <summary>
/// Slice a flat S7 byte buffer into a typed array using the existing big-endian scalar
/// codec for each element. Returns the typed array boxed as <c>object</c> so the
/// <see cref="DataValueSnapshot"/> surface can carry it without further conversion.
/// </summary>
internal static object SliceArray(byte[] bytes, S7DataType t, int n, int elemBytes)
{
switch (t)
{
case S7DataType.Byte:
{
var a = new byte[n];
Buffer.BlockCopy(bytes, 0, a, 0, n);
return a;
}
case S7DataType.Int16:
{
var a = new short[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.UInt16:
{
var a = new ushort[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.Int32:
{
var a = new int[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
return a;
}
case S7DataType.UInt32:
{
var a = new uint[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
return a;
}
case S7DataType.Int64:
{
var a = new long[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
return a;
}
case S7DataType.UInt64:
{
var a = new ulong[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
return a;
}
case S7DataType.Float32:
{
var a = new float[n];
for (var i = 0; i < n; i++)
a[i] = BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4)));
return a;
}
case S7DataType.Float64:
{
var a = new double[n];
for (var i = 0; i < n; i++)
a[i] = BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8)));
return a;
}
case S7DataType.Date:
{
var a = new DateTime[n];
for (var i = 0; i < n; i++)
a[i] = S7DateTimeCodec.DecodeDate(bytes.AsSpan(i * elemBytes, 2));
return a;
}
case S7DataType.Time:
{
// Surface as Int32 ms — matches the scalar Time read path (driver-specs §5).
var a = new int[n];
for (var i = 0; i < n; i++)
a[i] = (int)S7DateTimeCodec.DecodeTime(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
return a;
}
case S7DataType.TimeOfDay:
{
var a = new int[n];
for (var i = 0; i < n; i++)
a[i] = (int)S7DateTimeCodec.DecodeTod(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
return a;
}
default:
throw new InvalidOperationException($"S7 array slice undefined for {t}");
}
}
/// <summary>
/// Pack a caller-supplied array (object) into the on-wire S7 byte layout for
/// <paramref name="elementType"/>. Accepts both the strongly-typed array
/// (<c>short[]</c>, <c>int[]</c>, ...) and a generic <c>System.Array</c> / <c>IEnumerable</c>
/// so OPC UA Variant-boxed values flow through unchanged.
/// </summary>
internal static byte[] PackArray(object value, S7DataType elementType, int n, int elemBytes, string tagName)
{
if (value is not System.Collections.IEnumerable enumerable)
throw new ArgumentException($"S7 Write tag '{tagName}' is array but value is not enumerable (got {value.GetType().Name})", nameof(value));
var buf = new byte[n * elemBytes];
var i = 0;
foreach (var raw in enumerable)
{
if (i >= n)
throw new ArgumentException($"S7 Write tag '{tagName}': value has more than ElementCount={n} elements", nameof(value));
var span = buf.AsSpan(i * elemBytes, elemBytes);
switch (elementType)
{
case S7DataType.Byte: span[0] = Convert.ToByte(raw); break;
case S7DataType.Int16: BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(raw)); break;
case S7DataType.UInt16: BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(raw)); break;
case S7DataType.Int32: BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(raw)); break;
case S7DataType.UInt32: BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(raw)); break;
case S7DataType.Int64: BinaryPrimitives.WriteInt64BigEndian(span, Convert.ToInt64(raw)); break;
case S7DataType.UInt64: BinaryPrimitives.WriteUInt64BigEndian(span, Convert.ToUInt64(raw)); break;
case S7DataType.Float32: BinaryPrimitives.WriteUInt32BigEndian(span, BitConverter.SingleToUInt32Bits(Convert.ToSingle(raw))); break;
case S7DataType.Float64: BinaryPrimitives.WriteUInt64BigEndian(span, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(raw))); break;
case S7DataType.Date:
S7DateTimeCodec.EncodeDate(Convert.ToDateTime(raw)).CopyTo(span);
break;
case S7DataType.Time:
S7DateTimeCodec.EncodeTime(raw is TimeSpan ts ? ts : TimeSpan.FromMilliseconds(Convert.ToInt32(raw))).CopyTo(span);
break;
case S7DataType.TimeOfDay:
S7DateTimeCodec.EncodeTod(raw is TimeSpan tod ? tod : TimeSpan.FromMilliseconds(Convert.ToInt64(raw))).CopyTo(span);
break;
default:
throw new InvalidOperationException($"S7 array pack undefined for {elementType}");
}
i++;
}
if (i != n)
throw new ArgumentException($"S7 Write tag '{tagName}': value had {i} elements, expected ElementCount={n}", nameof(value));
return buf;
}
private static DriverDataType MapDataType(S7DataType t) => t switch
{
S7DataType.Bool => DriverDataType.Boolean,

View File

@@ -95,13 +95,23 @@ 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="ElementCount">
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>&gt; 1</c> = array.
/// The driver issues one byte-range read covering <c>ElementCount × bytes-per-element</c>
/// and slices client-side via the existing scalar codec. Multi-dim arrays are deferred;
/// array-of-UDT lands with PR-S7-D2. Variable-width element types
/// (STRING/WSTRING/CHAR/WCHAR) and BOOL (packed bits) are rejected at init time —
/// they need bespoke layout handling and are tracked as a follow-up. Capped at 8000 to
/// keep the byte-range request inside a single S7 PDU envelope.
/// </param>
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254,
bool WriteIdempotent = false);
bool WriteIdempotent = false,
int? ElementCount = null);
public enum S7DataType
{

View File

@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for the S7 driver's 1-D array surface (PR-S7-A4). Wire-level round-trip
/// tests against a live S7 still need a real PLC, so these tests exercise the
/// driver's slice / pack helpers directly (visible via <c>InternalsVisibleTo</c>) and
/// the init-time validation that rejects unsupported element types and over-budget
/// ElementCount values up-front.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverArrayTests
{
[Fact]
public void Int16_array_roundtrip_via_pack_then_slice()
{
// Big-endian 16-bit elements: validate that PackArray + SliceArray round-trip
// a representative range including negatives and the boundary values.
var input = new short[] { 0, 1, -1, short.MinValue, short.MaxValue, 12345 };
var elem = S7Driver.ArrayElementBytes(S7DataType.Int16);
var bytes = S7Driver.PackArray(input, S7DataType.Int16, input.Length, elem, "t");
bytes.Length.ShouldBe(input.Length * elem);
// Sanity-check big-endian layout: element[2] = -1 → 0xFFFF at byte offset 4.
bytes[4].ShouldBe((byte)0xFF); bytes[5].ShouldBe((byte)0xFF);
var output = (short[])S7Driver.SliceArray(bytes, S7DataType.Int16, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Int32_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0, 1, -1, int.MinValue, int.MaxValue, 0x12345678 };
var elem = S7Driver.ArrayElementBytes(S7DataType.Int32);
var bytes = S7Driver.PackArray(input, S7DataType.Int32, input.Length, elem, "t");
var output = (int[])S7Driver.SliceArray(bytes, S7DataType.Int32, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Float32_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0f, 1.5f, -3.25f, float.MinValue, float.MaxValue, float.Epsilon };
var elem = S7Driver.ArrayElementBytes(S7DataType.Float32);
var bytes = S7Driver.PackArray(input, S7DataType.Float32, input.Length, elem, "t");
var output = (float[])S7Driver.SliceArray(bytes, S7DataType.Float32, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void Float64_array_roundtrip_via_pack_then_slice()
{
var input = new[] { 0d, Math.PI, -Math.E, double.MinValue, double.MaxValue };
var elem = S7Driver.ArrayElementBytes(S7DataType.Float64);
var bytes = S7Driver.PackArray(input, S7DataType.Float64, input.Length, elem, "t");
var output = (double[])S7Driver.SliceArray(bytes, S7DataType.Float64, input.Length, elem);
output.ShouldBe(input);
}
[Fact]
public void IsArrayElementSupported_rejects_strings_and_bool()
{
// Variable-width string types and BOOL (packed-bit layout) are explicit follow-ups —
// surface them as init-time rejections rather than mysterious BadInternalError on read.
S7Driver.IsArrayElementSupported(S7DataType.Bool).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.String).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.WString).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.Char).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.WChar).ShouldBeFalse();
S7Driver.IsArrayElementSupported(S7DataType.Int16).ShouldBeTrue();
S7Driver.IsArrayElementSupported(S7DataType.Float64).ShouldBeTrue();
}
[Fact]
public async Task Initialize_rejects_array_of_String_with_FormatException()
{
// Init-time guard: even if the address parses cleanly, an array of variable-width
// STRING is not yet supported and must fail-fast at config-load. The driver never
// gets as far as opening the TcpClient because parsing is the first step.
var opts = new S7DriverOptions
{
Host = "192.0.2.1", // reserved — TCP would never connect anyway
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "BadStrArr",
Address: "DB1.DBB0",
DataType: S7DataType.String,
ElementCount: 4),
],
};
using var drv = new S7Driver(opts, "s7-arr-bad-string");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
[Fact]
public async Task Initialize_rejects_array_of_Bool_with_FormatException()
{
// BOOL arrays are stored as packed bits (one bit per element rounded up to a byte) —
// the byte-range read trick used here for word-shaped elements doesn't generalize, so
// arrays of Bool are explicitly out-of-scope for PR-S7-A4 and reject at init.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "BadBoolArr",
Address: "DB1.DBX0.0",
DataType: S7DataType.Bool,
ElementCount: 8),
],
};
using var drv = new S7Driver(opts, "s7-arr-bad-bool");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
[Fact]
public async Task Initialize_rejects_oversized_ElementCount_with_FormatException()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags =
[
new S7TagDefinition(
Name: "TooBig",
Address: "DB1.DBW0",
DataType: S7DataType.Int16,
ElementCount: S7Driver.MaxArrayElements + 1),
],
};
using var drv = new S7Driver(opts, "s7-arr-too-big");
await Should.ThrowAsync<FormatException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
}