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
167 lines
7.5 KiB
C#
167 lines
7.5 KiB
C#
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;
|
||
}
|
||
}
|