From ce98c2ada3a3b0d67db14a91183a087de2ee88c8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 16:49:02 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-a4=20=E2=80=94=20array=20tags=20(Val?= =?UTF-8?q?ueRank=3D1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 256 +++++++++++++++++- .../S7DriverOptions.cs | 12 +- .../S7DriverArrayTests.cs | 146 ++++++++++ 3 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverArrayTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 5a441bb..5c7cfa3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -54,6 +54,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) /// OPC UA StatusCode used when S7 returns ErrorCode.WrongCPU / PUT/GET disabled. private const uint StatusBadDeviceFailure = 0x80550000u; + /// + /// Hard upper bound on . 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. + /// + internal const int MaxArrayElements = 8000; + private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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; } + /// + /// True when 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 N × elementBytes byte-range read and ship as a follow-up. + /// + 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; + + /// + /// 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. + /// + 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}"), + }; + + /// + /// 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 object so the + /// surface can carry it without further conversion. + /// + 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}"); + } + } + + /// + /// Pack a caller-supplied array (object) into the on-wire S7 byte layout for + /// . Accepts both the strongly-typed array + /// (short[], int[], ...) and a generic System.Array / IEnumerable + /// so OPC UA Variant-boxed values flow through unchanged. + /// + 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, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs index 6b1cd26..3832f5c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs @@ -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. /// +/// +/// Optional 1-D array length. null (or 1) = scalar tag; > 1 = array. +/// The driver issues one byte-range read covering ElementCount × bytes-per-element +/// 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. +/// 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 { diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverArrayTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverArrayTests.cs new file mode 100644 index 0000000..5aaf936 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverArrayTests.cs @@ -0,0 +1,146 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// 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 InternalsVisibleTo) and +/// the init-time validation that rejects unsupported element types and over-budget +/// ElementCount values up-front. +/// +[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(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(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(async () => + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + } +} -- 2.49.1