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));
+ }
+}