From 1e5fec2f85dad57f39a665469a590aa79031415f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 05:50:49 -0400 Subject: [PATCH] feat(s7): String (S7 STRING) scalar read+write via S7.Net.Types.S7String --- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 15 +- .../S7ScalarBlockTests.cs | 161 +++++++++++++++--- 2 files changed, 154 insertions(+), 22 deletions(-) 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 6969602f..972aee74 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -618,8 +618,11 @@ public sealed class S7Driver S7DataType.UInt64 => System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block), S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block), - S7DataType.String => throw new NotSupportedException( - "S7 String scalar reads land in a follow-up PR"), + // S7 classic STRING: [maxLen byte][curLen byte][chars…]. S7.Net's S7String.FromByteArray + // reads the two-byte header and returns exactly curLen ASCII chars, ignoring the reserved + // padding past curLen — it validates curLen against the block, so no extra guard is needed. + S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block), + S7DataType.DateTime => throw new NotSupportedException( "S7 DateTime scalar reads land in a follow-up PR"), @@ -662,7 +665,13 @@ public sealed class S7Driver } case S7DataType.String: - throw new NotSupportedException("S7 String scalar writes land in a follow-up PR"); + // S7.Net's S7String.ToByteArray builds [maxLen=StringLength][curLen][chars…] and pads + // the result to the full reserved field (StringLength + 2 bytes) — exactly the width + // ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value + // longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT + // silently truncate). A null value encodes as the empty string. + return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); + case S7DataType.DateTime: throw new NotSupportedException("S7 DateTime scalar writes land in a follow-up PR"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs index 84aaba7a..736f5b65 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs @@ -1,5 +1,6 @@ using Shouldly; using Xunit; +using S7.Net.Types; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; @@ -10,8 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// Int64/UInt64/LReal (Float64) scalar from a contiguous big-endian byte block — the /// network I/O half (Plc.ReadBytesAsync/WriteBytesAsync) has no in-process /// fake so only the codec is unit-proven (mirrors ). -/// String/DateTime decode is a deferred stub here (T3/T4); this file pins the -/// NotSupportedException contract those land against. +/// String (S7 classic STRING) decode/encode is proven here via +/// ; DateTime decode is still a deferred stub (T4) and +/// this file pins the NotSupportedException contract it lands against. /// [Trait("Category", "Unit")] public sealed class S7ScalarBlockTests @@ -154,38 +156,159 @@ public sealed class S7ScalarBlockTests decoded.ShouldBeOfType().ShouldBe(value); } - /// Verifies Float64 (LReal) round-trips for representative doubles. + /// Verifies Float64 (LReal) round-trips for representative doubles, including + /// the IEEE-754 specials (NaN / ±Infinity) — these pass through BinaryPrimitives unchanged. [Theory] [InlineData(0.0)] [InlineData(3.141592653589793)] [InlineData(-2.5e-300)] [InlineData(1.7976931348623157e308)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] public void Float64_round_trips(double value) { var tag = Tag(S7DataType.Float64); var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); - decoded.ShouldBeOfType().ShouldBe(value); + // double.NaN != double.NaN, so compare via Shouldly's IsNaN for that case. + if (double.IsNaN(value)) + decoded.ShouldBeOfType().ShouldBe(double.NaN); + else + decoded.ShouldBeOfType().ShouldBe(value); } - // ── Deferred-stub contract: String/DateTime throw NotSupportedException ──────────────── + // ── DecodeScalarBlock — String (S7 classic STRING) ──────────────────────────────────── - /// Verifies String/DateTime decode is a deferred stub (NotSupportedException — T3/T4). - /// The not-yet-implemented wide type. - [Theory] - [InlineData(S7DataType.String)] - [InlineData(S7DataType.DateTime)] - public void DecodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt) + /// Verifies an S7 STRING block decodes to its C# string, ignoring the + /// two-byte [maxLen][curLen] header and any reserved padding past curLen. + [Fact] + public void DecodeScalarBlock_String_reads_header_and_chars() { - var tag = Tag(dt, stringLength: 10); + // S7 classic STRING layout: [maxLen=10][curLen=5]['H']['E']['L']['L']['O'][pad…]. + var block = S7String.ToByteArray("HELLO", 10); + block.Length.ShouldBe(12); // StringLength(10) + 2-byte header. + block[0].ShouldBe((byte)10); // maxLen. + block[1].ShouldBe((byte)5); // curLen. + + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); + result.ShouldBeOfType().ShouldBe("HELLO"); + } + + /// Verifies a hand-built STRING block (not produced by S7.Net) still decodes, + /// pinning the on-the-wire layout we depend on. + [Fact] + public void DecodeScalarBlock_String_decodes_hand_built_block() + { + var block = new byte[12]; + block[0] = 10; // maxLen. + block[1] = 5; // curLen. + block[2] = (byte)'H'; block[3] = (byte)'E'; block[4] = (byte)'L'; + block[5] = (byte)'L'; block[6] = (byte)'O'; // bytes 7..11 stay zero (reserved padding). + + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); + result.ShouldBeOfType().ShouldBe("HELLO"); + } + + /// Verifies an empty S7 STRING decodes to the empty string. + [Fact] + public void DecodeScalarBlock_String_empty() + { + var block = S7String.ToByteArray("", 10); + var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.String, stringLength: 10), Addr(), block); + result.ShouldBeOfType().ShouldBe(""); + } + + // ── EncodeScalarBlock — String (S7 classic STRING) ──────────────────────────────────── + + /// Verifies a C# string encodes to a STRING block sized to the reserved field + /// (StringLength + 2), with header [maxLen=StringLength][curLen=value.Length] and + /// the chars laid out after it. + [Fact] + public void EncodeScalarBlock_String_writes_header_chars_and_pads_to_reserved() + { + var tag = Tag(S7DataType.String, stringLength: 10); + var block = S7Driver.EncodeScalarBlock(tag, "HELLO"); + + block.Length.ShouldBe(12); // StringLength(10) + 2 — full reserved field, not curLen-sized. + block[0].ShouldBe((byte)10); // maxLen == declared StringLength. + block[1].ShouldBe((byte)5); // curLen == value.Length. + block[2].ShouldBe((byte)'H'); + block[6].ShouldBe((byte)'O'); + block[7].ShouldBe((byte)0); // reserved padding is zeroed. + block[11].ShouldBe((byte)0); + } + + /// Verifies a null value encodes as an empty STRING (curLen 0), not a throw. + [Fact] + public void EncodeScalarBlock_String_null_value_is_empty_string() + { + var tag = Tag(S7DataType.String, stringLength: 10); + var block = S7Driver.EncodeScalarBlock(tag, value: null); + + block.Length.ShouldBe(12); + block[0].ShouldBe((byte)10); // maxLen. + block[1].ShouldBe((byte)0); // curLen 0. + } + + /// Verifies a non-string value coerces via Convert.ToString before encoding. + [Fact] + public void EncodeScalarBlock_String_coerces_non_string_value() + { + var tag = Tag(S7DataType.String, stringLength: 10); + var block = S7Driver.EncodeScalarBlock(tag, 1234); + + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block); + decoded.ShouldBeOfType().ShouldBe("1234"); + } + + /// Verifies a string longer than the reserved length throws (S7.Net rejects it; + /// we do NOT silently truncate). Pins the overflow behaviour. + [Fact] + public void EncodeScalarBlock_String_overflow_throws() + { + var tag = Tag(S7DataType.String, stringLength: 5); + // 6 chars into a 5-char reserved field — S7.Net throws ArgumentException. + Should.Throw(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF")); + } + + // ── String round-trip identity (encode → decode) ────────────────────────────────────── + + /// Verifies String round-trips through encode→decode, incl. empty and max-length. + [Theory] + [InlineData("")] + [InlineData("A")] + [InlineData("Hello, S7!")] // 10 chars == StringLength (at max). + [InlineData("Tag_Value 42")] + public void String_round_trips(string value) + { + // StringLength sized to fit the longest sample (12) so none overflow. + var tag = Tag(S7DataType.String, stringLength: 12); + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); + decoded.ShouldBeOfType().ShouldBe(value); + } + + /// Verifies a string exactly at the reserved length round-trips (boundary, no overflow). + [Fact] + public void String_at_max_length_round_trips() + { + var tag = Tag(S7DataType.String, stringLength: 5); + var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, "ABCDE")); + decoded.ShouldBeOfType().ShouldBe("ABCDE"); + } + + // ── Deferred-stub contract: DateTime throws NotSupportedException ────────────────────── + + /// Verifies DateTime decode is a deferred stub (NotSupportedException — T4). + [Fact] + public void DecodeScalarBlock_DateTime_throws_NotSupported() + { + var tag = Tag(S7DataType.DateTime); var block = new byte[S7Driver.ScalarByteWidth(tag)]; Should.Throw(() => S7Driver.DecodeScalarBlock(tag, Addr(), block)); } - /// Verifies String/DateTime encode is a deferred stub (NotSupportedException — T3/T4). - /// The not-yet-implemented wide type. - [Theory] - [InlineData(S7DataType.String)] - [InlineData(S7DataType.DateTime)] - public void EncodeScalarBlock_String_or_DateTime_throws_NotSupported(S7DataType dt) - => Should.Throw(() => S7Driver.EncodeScalarBlock(Tag(dt), "x")); + /// Verifies DateTime encode is a deferred stub (NotSupportedException — T4). + [Fact] + public void EncodeScalarBlock_DateTime_throws_NotSupported() + => Should.Throw(() => S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), "x")); }