diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs index 9dd8fccb..6b5005fc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs @@ -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. /// +/// +/// Element count when the tag is a 1-D array; null (or <= 1) for a scalar. +/// For an equipment tag this is threaded from the TagConfig JSON's arrayLength +/// (honoured only when isArray is true) by . When +/// set, the driver issues a single contiguous block read of +/// ArrayCount × element-bytes from the tag's start address and decodes each element +/// into an element-typed CLR array (short[] / int[] / float[] / etc.). +/// 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 { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs index f16c5780..8430335c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs @@ -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; + + /// + /// Reads the optional 1-D array element count from the TagConfig blob. Returns the + /// arrayLength int ONLY when isArray is true AND arrayLength + /// is a positive JSON integer; null otherwise (scalar). Mirrors the byte-parity + /// contract of DeploymentArtifact.ExtractTagArray on the sink side. + /// + 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; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index de1d15d6..dbb9a475 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -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); } + /// + /// Reads a 1-D array tag as ONE contiguous block (count × element-bytes) via + /// S7.Net's buffer-based Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct) + /// — a single PLC round-trip, NOT N string reads — then hands the raw byte block + /// to the pure decode loop. Timer/Counter areas are + /// already rejected at init, so only DB/M/I/Q reach here. + /// + private async Task 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); + } + + /// 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. + /// The parsed access width. + /// Element byte size: Bit/Byte = 1, Word = 2, DWord = 4. + 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}"), + }; + + /// + /// Maps the driver's to S7.Net's DataType for the + /// buffer-based block read. Timer/Counter are rejected at init so they never reach the + /// array path. + /// + 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)"), + }; + + /// + /// Pure decode loop — turns a raw S7 (big-endian) byte block into an element-typed CLR + /// array (short[] / ushort[] / int[] / uint[] / float[] + /// / byte[] / bool[]), boxed as . No network I/O — + /// factored out of 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 i × element-bytes slice using S7 big-endian byte + /// order, identical to the per-element semantics of . + /// + /// Tag definition carrying the element and array count. + /// Parsed address carrying the access . + /// Raw contiguous byte block read from the PLC (length == count × element-bytes). + /// An element-typed CLR array boxed as . + 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}"); + } + } + + /// Reads a big-endian 16-bit word from at . + private static ushort ReadBeUInt16(byte[] block, int offset) => + (ushort)((block[offset] << 8) | block[offset + 1]); + + /// Reads a big-endian 32-bit dword from at . + 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]; + /// /// Pure reinterpret step — converts the boxed value that S7.Net returns (always an /// unsigned type: bool, byte, ushort, uint) 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, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs new file mode 100644 index 00000000..92167a0c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayReadTests.cs @@ -0,0 +1,266 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Unit tests for the S7 1-D array support: the pure +/// 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, Plc.ReadBytesAsync, has no in-process fake so only the +/// decode is unit-proven), the IsArray/ArrayDim +/// flip, and the equipment-tag resolver threading arrayLength into the transient +/// tag-def's array count. +/// +[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 ─────────────────────────────────────── + + /// Verifies an Int16 array decodes to a typed short[] with big-endian values. + [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(); + arr.ShouldBe(new short[] { 1, -1, 32767 }); + } + + /// Verifies a UInt16 array decodes to a typed ushort[]. + [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().ShouldBe(new ushort[] { 0, 1000, 65535 }); + } + + /// Verifies an Int32 array decodes to a typed int[] with big-endian dwords. + [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().ShouldBe(new[] { 1, -1, int.MaxValue }); + } + + /// Verifies a UInt32 array decodes to a typed uint[]. + [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().ShouldBe(new uint[] { 0, 70_000, 0xFFFF_FFFF }); + } + + /// Verifies a Float32 array decodes to a typed float[] from IEEE-754 big-endian dwords. + [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(); + 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); + } + + /// Verifies a Byte array decodes to a typed byte[] (one element per byte). + [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().ShouldBe(new byte[] { 0, 42, 200, 255 }); + } + + /// Verifies a Bool array decodes from packed bits (one byte → low bit per element). + [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().ShouldBe(new[] { true, false, true }); + } + + /// Verifies the array length matches the tag's declared count. + [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().Length.ShouldBe(5); + } + + /// Verifies unsupported element types throw NotSupportedException in the array path. + /// The unsupported S7 data type. + [Theory] + [InlineData(S7DataType.Int64)] + [InlineData(S7DataType.Float64)] + [InlineData(S7DataType.String)] + public void DecodeArrayBlock_unsupported_element_type_throws(S7DataType dt) + { + Should.Throw(() => + S7Driver.DecodeArrayBlock(ArrTag(dt, 2), Addr(S7Size.DWord), new byte[16])); + } + + // ── ElementByteSize — block-read sizing ─────────────────────────────────────────────── + + /// Verifies element byte sizes used to size the contiguous block read. + [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(); + } + } + + /// Verifies an array tag is discovered with IsArray=true and ArrayDim=count. + [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); + } + + /// Verifies a tag with ArrayCount of 1 is treated as a scalar (no array node). + [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 ─────────────────────────── + + /// Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount. + [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); + } + + /// Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation). + [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"); + } + + /// Verifies a scalar equipment tag (no array keys) has a null ArrayCount. + [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(); + } +}