Auto: s7-a2 — STRING/WSTRING/CHAR/WCHAR
Closes the NotSupportedException cliff for S7 string-shaped types.
- S7DataType gains WString, Char, WChar members alongside the existing
String entry.
- New S7StringCodec encodes/decodes the four wire formats:
STRING : 2-byte header (max-len + actual-len bytes) + N ASCII bytes
-> total 2 + max_len.
WSTRING : 4-byte header (max-len + actual-len UInt16 BE) + N×2
UTF-16BE bytes -> total 4 + 2 × max_len.
CHAR : 1 ASCII byte (rejects non-ASCII on encode).
WCHAR : 2 UTF-16BE bytes.
Header-bug clamp: actualLen > maxLen is silently clamped on read so
firmware quirks don't walk past the wire buffer; rejected on write
to avoid silent truncation.
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
WriteBytesAsync against the parsed Area / DbNumber / ByteOffset and
honour S7TagDefinition.StringLength (default 254 = S7 STRING max).
- MapDataType returns DriverDataType.String for the three new enum
members so OPC UA discovery surfaces them as scalar strings.
Tests: 21 new cases on S7StringCodec covering golden-byte vectors,
encode/decode round-trips, the firmware-bug header-clamp, ASCII-only
guard on CHAR, and the StringLength default. 85/85 passing.
Closes #288
This commit is contained in:
@@ -222,6 +222,54 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
{
|
{
|
||||||
var addr = _parsedByName[tag.Name];
|
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
|
// 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.
|
// 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)
|
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.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(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"),
|
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||||
|
|
||||||
_ => throw new System.IO.InvalidDataException(
|
_ => 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)
|
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
|
// 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
|
// 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.
|
// 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.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(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"),
|
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||||
};
|
};
|
||||||
@@ -418,6 +487,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
S7DataType.Float32 => DriverDataType.Float32,
|
S7DataType.Float32 => DriverDataType.Float32,
|
||||||
S7DataType.Float64 => DriverDataType.Float64,
|
S7DataType.Float64 => DriverDataType.Float64,
|
||||||
S7DataType.String => DriverDataType.String,
|
S7DataType.String => DriverDataType.String,
|
||||||
|
S7DataType.WString => DriverDataType.String,
|
||||||
|
S7DataType.Char => DriverDataType.String,
|
||||||
|
S7DataType.WChar => DriverDataType.String,
|
||||||
S7DataType.DateTime => DriverDataType.DateTime,
|
S7DataType.DateTime => DriverDataType.DateTime,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -116,5 +116,11 @@ public enum S7DataType
|
|||||||
Float32,
|
Float32,
|
||||||
Float64,
|
Float64,
|
||||||
String,
|
String,
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
WString,
|
||||||
|
/// <summary>S7 CHAR: single ASCII byte.</summary>
|
||||||
|
Char,
|
||||||
|
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
|
||||||
|
WChar,
|
||||||
DateTime,
|
DateTime,
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING,
|
||||||
|
/// CHAR, WCHAR. Pulled out of <see cref="S7Driver"/> so the encoding rules are
|
||||||
|
/// unit-testable against golden byte vectors without standing up a Plc instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <b>STRING</b>: 2-byte header (<c>maxLen</c> byte, <c>actualLen</c> byte) +
|
||||||
|
/// N ASCII bytes. Total slot size on the PLC = <c>2 + maxLen</c>. Bytes past
|
||||||
|
/// <c>actualLen</c> are unspecified — the codec ignores them on read.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>WSTRING</b>: 4-byte header (<c>maxLen</c> UInt16 BE, <c>actualLen</c>
|
||||||
|
/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC =
|
||||||
|
/// <c>4 + 2 × maxLen</c>.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>CHAR</b>: 1 ASCII byte.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <b>WCHAR</b>: 2 UTF-16BE bytes.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Header-bug clamp</b>: certain S7 firmware revisions write
|
||||||
|
/// <c>actualLen > maxLen</c> (observed with NULL-padded buffers from older
|
||||||
|
/// CP-modules). On <i>read</i> the codec clamps the effective length so it never
|
||||||
|
/// walks past the wire buffer. On <i>write</i> the codec rejects the input
|
||||||
|
/// outright — silently truncating produces silent data loss.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class S7StringCodec
|
||||||
|
{
|
||||||
|
/// <summary>Buffer size for a STRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||||
|
public static int StringBufferSize(int maxLen) => 2 + maxLen;
|
||||||
|
|
||||||
|
/// <summary>Buffer size for a WSTRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||||
|
public static int WStringBufferSize(int maxLen) => 4 + (2 * maxLen);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode an S7 STRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||||
|
/// must be exactly <c>2 + maxLen</c> long. <c>actualLen</c> is clamped to the
|
||||||
|
/// declared <paramref name="maxLen"/> if the firmware reported an out-of-spec
|
||||||
|
/// value (header-bug tolerance).
|
||||||
|
/// </summary>
|
||||||
|
public static string DecodeString(ReadOnlySpan<byte> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode a .NET string into an S7 STRING wire buffer of length
|
||||||
|
/// <c>2 + maxLen</c>. ASCII only — non-ASCII characters are encoded as <c>?</c>
|
||||||
|
/// by <see cref="Encoding.ASCII"/>. Throws if <paramref name="value"/> is longer
|
||||||
|
/// than <paramref name="maxLen"/>.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode an S7 WSTRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||||
|
/// must be exactly <c>4 + 2 × maxLen</c> long. <c>actualLen</c> is clamped to
|
||||||
|
/// <paramref name="maxLen"/> on read.
|
||||||
|
/// </summary>
|
||||||
|
public static string DecodeWString(ReadOnlySpan<byte> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encode a .NET string into an S7 WSTRING wire buffer of length
|
||||||
|
/// <c>4 + 2 × maxLen</c>. Throws if <paramref name="value"/> has more than
|
||||||
|
/// <paramref name="maxLen"/> UTF-16 code units.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decode a single S7 CHAR (one ASCII byte).</summary>
|
||||||
|
public static char DecodeChar(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != 1)
|
||||||
|
throw new InvalidDataException($"S7 CHAR expected 1 byte, got {bytes.Length}");
|
||||||
|
return (char)bytes[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a single ASCII char into an S7 CHAR (one byte). Non-ASCII rejected.</summary>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Decode a single S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||||
|
public static char DecodeWChar(ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
if (bytes.Length != 2)
|
||||||
|
throw new InvalidDataException($"S7 WCHAR expected 2 bytes, got {bytes.Length}");
|
||||||
|
return (char)BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Encode a single char into an S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||||
|
public static byte[] EncodeWChar(char value)
|
||||||
|
{
|
||||||
|
var buf = new byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf, value);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs
Normal file
228
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7StringCodecTests.cs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Golden-byte unit tests for <see cref="S7StringCodec"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<byte>(10); // declared max
|
||||||
|
bytes[1].ShouldBe<byte>(5); // actual length
|
||||||
|
// ASCII payload
|
||||||
|
bytes[2].ShouldBe<byte>((byte)'H');
|
||||||
|
bytes[3].ShouldBe<byte>((byte)'E');
|
||||||
|
bytes[4].ShouldBe<byte>((byte)'L');
|
||||||
|
bytes[5].ShouldBe<byte>((byte)'L');
|
||||||
|
bytes[6].ShouldBe<byte>((byte)'O');
|
||||||
|
// Padding bytes left as 0x00.
|
||||||
|
for (var i = 7; i < bytes.Length; i++) bytes[i].ShouldBe<byte>(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<ArgumentException>(() => 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<System.IO.InvalidDataException>(() => 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<byte>(0x00); // maxLen high
|
||||||
|
bytes[1].ShouldBe<byte>(0x04); // maxLen low
|
||||||
|
bytes[2].ShouldBe<byte>(0x00); // actualLen high
|
||||||
|
bytes[3].ShouldBe<byte>(0x02); // actualLen low
|
||||||
|
bytes[4].ShouldBe<byte>(0x00); // 'H' high (BE)
|
||||||
|
bytes[5].ShouldBe<byte>(0x48); // 'H' low
|
||||||
|
bytes[6].ShouldBe<byte>(0x00); // 'i' high
|
||||||
|
bytes[7].ShouldBe<byte>(0x69); // 'i' low
|
||||||
|
// Padding bytes [8..11] left as 0x00.
|
||||||
|
bytes[8].ShouldBe<byte>(0);
|
||||||
|
bytes[9].ShouldBe<byte>(0);
|
||||||
|
bytes[10].ShouldBe<byte>(0);
|
||||||
|
bytes[11].ShouldBe<byte>(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<ArgumentException>(() => S7StringCodec.EncodeWString("TOO-LONG", maxLen: 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecodeWString_rejects_wrong_length_buffer()
|
||||||
|
{
|
||||||
|
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(0x41);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecodeChar_round_trips()
|
||||||
|
{
|
||||||
|
S7StringCodec.DecodeChar(new byte[] { 0x5A }).ShouldBe('Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncodeChar_rejects_non_ascii()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => S7StringCodec.EncodeChar('é'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecodeChar_rejects_wrong_length()
|
||||||
|
{
|
||||||
|
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(0x00);
|
||||||
|
b[1].ShouldBe<byte>(0x5A);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncodeWChar_handles_unicode_codepoint()
|
||||||
|
{
|
||||||
|
// U+00E9 (é) -> 00 E9 BE
|
||||||
|
var b = S7StringCodec.EncodeWChar('é');
|
||||||
|
b[0].ShouldBe<byte>(0x00);
|
||||||
|
b[1].ShouldBe<byte>(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<System.IO.InvalidDataException>(() => S7StringCodec.DecodeWChar(new byte[1]));
|
||||||
|
Should.Throw<System.IO.InvalidDataException>(() => 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<byte>(254);
|
||||||
|
bytes[1].ShouldBe<byte>(100);
|
||||||
|
S7StringCodec.DecodeString(bytes, 254).ShouldBe(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncodeString_rejects_max_length_above_254()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() => S7StringCodec.EncodeString("x", maxLen: 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user