diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index a1af29e..a942680 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -222,6 +222,54 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) { var addr = _parsedByName[tag.Name]; + // 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. + if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) + { + if (addr.Size == S7Size.Bit) + throw new System.IO.InvalidDataException( + $"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " + + $"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)"); + + var (area, dbNum, off) = (addr.Area, addr.DbNumber, addr.ByteOffset); + switch (tag.DataType) + { + case S7DataType.Char: + { + var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 1, ct).ConfigureAwait(false); + if (b is null || b.Length != 1) + throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for CHAR '{tag.Address}', expected 1"); + return S7StringCodec.DecodeChar(b); + } + case S7DataType.WChar: + { + var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 2, ct).ConfigureAwait(false); + if (b is null || b.Length != 2) + throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WCHAR '{tag.Address}', expected 2"); + return S7StringCodec.DecodeWChar(b); + } + case S7DataType.String: + { + var max = tag.StringLength; + var size = S7StringCodec.StringBufferSize(max); + var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false); + if (b is null || b.Length != size) + throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for STRING '{tag.Address}', expected {size}"); + return S7StringCodec.DecodeString(b, max); + } + case S7DataType.WString: + { + var max = tag.StringLength; + var size = S7StringCodec.WStringBufferSize(max); + var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false); + if (b is null || b.Length != size) + throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WSTRING '{tag.Address}', expected {size}"); + return S7StringCodec.DecodeWString(b, max); + } + } + } + // 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an // 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE. if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64) @@ -262,7 +310,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) (S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32), (S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32), - (S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"), (S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"), _ => throw new System.IO.InvalidDataException( @@ -332,6 +379,29 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) { + // 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. + if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) + { + var addr = _parsedByName[tag.Name]; + if (addr.Size == S7Size.Bit) + throw new InvalidOperationException( + $"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " + + $"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)"); + + byte[] payload = tag.DataType switch + { + S7DataType.Char => S7StringCodec.EncodeChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))), + S7DataType.WChar => S7StringCodec.EncodeWChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))), + S7DataType.String => S7StringCodec.EncodeString(Convert.ToString(value) ?? string.Empty, tag.StringLength), + S7DataType.WString => S7StringCodec.EncodeWString(Convert.ToString(value) ?? string.Empty, tag.StringLength), + _ => throw new InvalidOperationException(), + }; + await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false); + return; + } + // 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit // the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a // BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path. @@ -374,7 +444,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)), S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)), - S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"), S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"), _ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"), }; @@ -418,6 +487,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) S7DataType.Float32 => DriverDataType.Float32, S7DataType.Float64 => DriverDataType.Float64, S7DataType.String => DriverDataType.String, + S7DataType.WString => DriverDataType.String, + S7DataType.Char => DriverDataType.String, + S7DataType.WChar => DriverDataType.String, S7DataType.DateTime => DriverDataType.DateTime, _ => DriverDataType.Int32, }; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs index c3cc172..567144d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs @@ -116,5 +116,11 @@ public enum S7DataType Float32, Float64, String, + /// S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength. + WString, + /// S7 CHAR: single ASCII byte. + Char, + /// S7 WCHAR: two bytes UTF-16 big-endian. + WChar, DateTime, } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs new file mode 100644 index 0000000..45c4874 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs @@ -0,0 +1,166 @@ +using System.Buffers.Binary; +using System.Text; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING, +/// CHAR, WCHAR. Pulled out of so the encoding rules are +/// unit-testable against golden byte vectors without standing up a Plc instance. +/// +/// +/// Wire formats (all big-endian, matching S7's native byte order): +/// +/// +/// STRING: 2-byte header (maxLen byte, actualLen byte) + +/// N ASCII bytes. Total slot size on the PLC = 2 + maxLen. Bytes past +/// actualLen are unspecified — the codec ignores them on read. +/// +/// +/// WSTRING: 4-byte header (maxLen UInt16 BE, actualLen +/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC = +/// 4 + 2 × maxLen. +/// +/// +/// CHAR: 1 ASCII byte. +/// +/// +/// WCHAR: 2 UTF-16BE bytes. +/// +/// +/// +/// Header-bug clamp: certain S7 firmware revisions write +/// actualLen > maxLen (observed with NULL-padded buffers from older +/// CP-modules). On read the codec clamps the effective length so it never +/// walks past the wire buffer. On write the codec rejects the input +/// outright — silently truncating produces silent data loss. +/// +/// +public static class S7StringCodec +{ + /// Buffer size for a STRING tag with the given declared . + public static int StringBufferSize(int maxLen) => 2 + maxLen; + + /// Buffer size for a WSTRING tag with the given declared . + public static int WStringBufferSize(int maxLen) => 4 + (2 * maxLen); + + /// + /// Decode an S7 STRING wire buffer into a .NET string. + /// must be exactly 2 + maxLen long. actualLen is clamped to the + /// declared if the firmware reported an out-of-spec + /// value (header-bug tolerance). + /// + public static string DecodeString(ReadOnlySpan bytes, int maxLen) + { + if (maxLen is < 1 or > 254) + throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254"); + var expected = StringBufferSize(maxLen); + if (bytes.Length != expected) + throw new InvalidDataException($"S7 STRING expected {expected} bytes, got {bytes.Length}"); + + // bytes[0] = declared max-length (advisory; we trust the caller-provided maxLen). + // bytes[1] = actual length. Clamp on read — firmware bug fallback. + int actual = bytes[1]; + if (actual > maxLen) actual = maxLen; + if (actual == 0) return string.Empty; + return Encoding.ASCII.GetString(bytes.Slice(2, actual)); + } + + /// + /// Encode a .NET string into an S7 STRING wire buffer of length + /// 2 + maxLen. ASCII only — non-ASCII characters are encoded as ? + /// by . Throws if is longer + /// than . + /// + public static byte[] EncodeString(string value, int maxLen) + { + ArgumentNullException.ThrowIfNull(value); + if (maxLen is < 1 or > 254) + throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254"); + if (value.Length > maxLen) + throw new ArgumentException( + $"S7 STRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value)); + + var buf = new byte[StringBufferSize(maxLen)]; + buf[0] = (byte)maxLen; + buf[1] = (byte)value.Length; + Encoding.ASCII.GetBytes(value, 0, value.Length, buf, 2); + // Trailing bytes [2 + value.Length .. end] left as 0x00; S7 PLCs treat them as + // don't-care because actualLen bounds the readable region. + return buf; + } + + /// + /// Decode an S7 WSTRING wire buffer into a .NET string. + /// must be exactly 4 + 2 × maxLen long. actualLen is clamped to + /// on read. + /// + public static string DecodeWString(ReadOnlySpan bytes, int maxLen) + { + if (maxLen < 1) + throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1"); + var expected = WStringBufferSize(maxLen); + if (bytes.Length != expected) + throw new InvalidDataException($"S7 WSTRING expected {expected} bytes, got {bytes.Length}"); + + // Header is two UInt16 BE: declared max-len and actual-len (both in characters). + int actual = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(2, 2)); + if (actual > maxLen) actual = maxLen; + if (actual == 0) return string.Empty; + return Encoding.BigEndianUnicode.GetString(bytes.Slice(4, actual * 2)); + } + + /// + /// Encode a .NET string into an S7 WSTRING wire buffer of length + /// 4 + 2 × maxLen. Throws if has more than + /// UTF-16 code units. + /// + public static byte[] EncodeWString(string value, int maxLen) + { + ArgumentNullException.ThrowIfNull(value); + if (maxLen < 1) + throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1"); + if (value.Length > maxLen) + throw new ArgumentException( + $"S7 WSTRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value)); + + var buf = new byte[WStringBufferSize(maxLen)]; + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)maxLen); + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), (ushort)value.Length); + if (value.Length > 0) + Encoding.BigEndianUnicode.GetBytes(value, 0, value.Length, buf, 4); + return buf; + } + + /// Decode a single S7 CHAR (one ASCII byte). + public static char DecodeChar(ReadOnlySpan bytes) + { + if (bytes.Length != 1) + throw new InvalidDataException($"S7 CHAR expected 1 byte, got {bytes.Length}"); + return (char)bytes[0]; + } + + /// Encode a single ASCII char into an S7 CHAR (one byte). Non-ASCII rejected. + public static byte[] EncodeChar(char value) + { + if (value > 0x7F) + throw new ArgumentException($"S7 CHAR value '{value}' (U+{(int)value:X4}) is not ASCII", nameof(value)); + return [(byte)value]; + } + + /// Decode a single S7 WCHAR (two bytes UTF-16 big-endian). + public static char DecodeWChar(ReadOnlySpan bytes) + { + if (bytes.Length != 2) + throw new InvalidDataException($"S7 WCHAR expected 2 bytes, got {bytes.Length}"); + return (char)BinaryPrimitives.ReadUInt16BigEndian(bytes); + } + + /// Encode a single char into an S7 WCHAR (two bytes UTF-16 big-endian). + public static byte[] EncodeWChar(char value) + { + var buf = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(buf, value); + return buf; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs new file mode 100644 index 0000000..d6bb581 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs @@ -0,0 +1,228 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Golden-byte unit tests for : STRING / WSTRING / CHAR / +/// WCHAR encode + decode round-trips and the firmware-bug header-clamp on read. +/// These tests intentionally don't touch S7.Net — the codec operates on raw byte +/// spans so reproducing the wire format here is sufficient to lock the contract. +/// +[Trait("Category", "Unit")] +public sealed class S7StringCodecTests +{ + // -------- STRING -------- + + [Fact] + public void EncodeString_emits_two_byte_header_and_ascii_payload() + { + var bytes = S7StringCodec.EncodeString("HELLO", maxLen: 10); + + bytes.Length.ShouldBe(2 + 10); // 2-byte header + max-len slot + bytes[0].ShouldBe(10); // declared max + bytes[1].ShouldBe(5); // actual length + // ASCII payload + bytes[2].ShouldBe((byte)'H'); + bytes[3].ShouldBe((byte)'E'); + bytes[4].ShouldBe((byte)'L'); + bytes[5].ShouldBe((byte)'L'); + bytes[6].ShouldBe((byte)'O'); + // Padding bytes left as 0x00. + for (var i = 7; i < bytes.Length; i++) bytes[i].ShouldBe(0); + } + + [Fact] + public void DecodeString_round_trips_encode() + { + var bytes = S7StringCodec.EncodeString("ABC", maxLen: 16); + var decoded = S7StringCodec.DecodeString(bytes, maxLen: 16); + decoded.ShouldBe("ABC"); + } + + [Fact] + public void DecodeString_clamps_when_actualLen_exceeds_maxLen_firmware_bug() + { + // Hand-craft a buffer where actualLen (255) > maxLen (10). Real firmware bug + // observed on legacy CP modules. Codec must clamp to maxLen rather than walk + // off the end of the wire buffer. + var max = 10; + var buf = new byte[2 + max]; + buf[0] = (byte)max; + buf[1] = 255; // out-of-spec actual + for (var i = 0; i < max; i++) buf[2 + i] = (byte)('A' + i); + + var s = S7StringCodec.DecodeString(buf, max); + s.Length.ShouldBe(max); + s.ShouldBe("ABCDEFGHIJ"); + } + + [Fact] + public void DecodeString_empty_actual_len_returns_empty_string() + { + var buf = new byte[2 + 8]; + buf[0] = 8; + buf[1] = 0; + S7StringCodec.DecodeString(buf, 8).ShouldBe(string.Empty); + } + + [Fact] + public void EncodeString_rejects_value_longer_than_maxLen() + { + Should.Throw(() => S7StringCodec.EncodeString("TOO-LONG", maxLen: 4)); + } + + [Fact] + public void DecodeString_rejects_wrong_length_buffer() + { + // 2 + 5 expected, give 3 — must throw rather than silently read. + var buf = new byte[3]; + Should.Throw(() => S7StringCodec.DecodeString(buf, 5)); + } + + // -------- WSTRING -------- + + [Fact] + public void EncodeWString_emits_four_byte_header_and_utf16be_payload() + { + // "Hi" -> H = 0x0048, i = 0x0069. UTF-16 BE wire bytes 00 48 00 69. + var bytes = S7StringCodec.EncodeWString("Hi", maxLen: 4); + + bytes.Length.ShouldBe(4 + 2 * 4); // 4-byte header + 2 × max-len bytes + bytes[0].ShouldBe(0x00); // maxLen high + bytes[1].ShouldBe(0x04); // maxLen low + bytes[2].ShouldBe(0x00); // actualLen high + bytes[3].ShouldBe(0x02); // actualLen low + bytes[4].ShouldBe(0x00); // 'H' high (BE) + bytes[5].ShouldBe(0x48); // 'H' low + bytes[6].ShouldBe(0x00); // 'i' high + bytes[7].ShouldBe(0x69); // 'i' low + // Padding bytes [8..11] left as 0x00. + bytes[8].ShouldBe(0); + bytes[9].ShouldBe(0); + bytes[10].ShouldBe(0); + bytes[11].ShouldBe(0); + } + + [Fact] + public void DecodeWString_round_trips_unicode() + { + // U+00E9 (é) — non-ASCII, exercises the BE encoding. + var input = "café"; + var bytes = S7StringCodec.EncodeWString(input, maxLen: 8); + var decoded = S7StringCodec.DecodeWString(bytes, maxLen: 8); + decoded.ShouldBe(input); + } + + [Fact] + public void DecodeWString_clamps_when_actualLen_exceeds_maxLen_firmware_bug() + { + var max = 4; + var buf = new byte[4 + 2 * max]; + // Header: max=4, actual=0xFFFF (firmware-bug). + buf[0] = 0x00; buf[1] = (byte)max; + buf[2] = 0xFF; buf[3] = 0xFF; + // Payload: 'A','B','C','D' (BE). + buf[4] = 0x00; buf[5] = (byte)'A'; + buf[6] = 0x00; buf[7] = (byte)'B'; + buf[8] = 0x00; buf[9] = (byte)'C'; + buf[10] = 0x00; buf[11] = (byte)'D'; + + var s = S7StringCodec.DecodeWString(buf, max); + s.ShouldBe("ABCD"); // clamped to maxLen × 2 bytes + } + + [Fact] + public void EncodeWString_rejects_value_longer_than_maxLen() + { + Should.Throw(() => S7StringCodec.EncodeWString("TOO-LONG", maxLen: 4)); + } + + [Fact] + public void DecodeWString_rejects_wrong_length_buffer() + { + Should.Throw(() => S7StringCodec.DecodeWString(new byte[5], maxLen: 4)); + } + + // -------- CHAR -------- + + [Fact] + public void EncodeChar_emits_single_ascii_byte() + { + var b = S7StringCodec.EncodeChar('A'); + b.Length.ShouldBe(1); + b[0].ShouldBe(0x41); + } + + [Fact] + public void DecodeChar_round_trips() + { + S7StringCodec.DecodeChar(new byte[] { 0x5A }).ShouldBe('Z'); + } + + [Fact] + public void EncodeChar_rejects_non_ascii() + { + Should.Throw(() => S7StringCodec.EncodeChar('é')); + } + + [Fact] + public void DecodeChar_rejects_wrong_length() + { + Should.Throw(() => S7StringCodec.DecodeChar(new byte[2])); + } + + // -------- WCHAR -------- + + [Fact] + public void EncodeWChar_emits_two_bytes_big_endian() + { + var b = S7StringCodec.EncodeWChar('Z'); + b.Length.ShouldBe(2); + b[0].ShouldBe(0x00); + b[1].ShouldBe(0x5A); + } + + [Fact] + public void EncodeWChar_handles_unicode_codepoint() + { + // U+00E9 (é) -> 00 E9 BE + var b = S7StringCodec.EncodeWChar('é'); + b[0].ShouldBe(0x00); + b[1].ShouldBe(0xE9); + } + + [Fact] + public void DecodeWChar_round_trips() + { + S7StringCodec.DecodeWChar(new byte[] { 0x00, 0x5A }).ShouldBe('Z'); + S7StringCodec.DecodeWChar(new byte[] { 0x00, 0xE9 }).ShouldBe('é'); + } + + [Fact] + public void DecodeWChar_rejects_wrong_length() + { + Should.Throw(() => S7StringCodec.DecodeWChar(new byte[1])); + Should.Throw(() => S7StringCodec.DecodeWChar(new byte[3])); + } + + // -------- StringLength default + range -------- + + [Fact] + public void EncodeString_default_max_length_254_round_trips() + { + // Default S7TagDefinition.StringLength is 254; codec must accept that. + var s = new string('x', 100); + var bytes = S7StringCodec.EncodeString(s, 254); + bytes.Length.ShouldBe(2 + 254); + bytes[0].ShouldBe(254); + bytes[1].ShouldBe(100); + S7StringCodec.DecodeString(bytes, 254).ShouldBe(s); + } + + [Fact] + public void EncodeString_rejects_max_length_above_254() + { + Should.Throw(() => S7StringCodec.EncodeString("x", maxLen: 255)); + } +}