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:
Joseph Doherty
2026-04-25 16:26:05 -04:00
parent 38eb909f69
commit 316f820eff
4 changed files with 474 additions and 2 deletions

View File

@@ -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,
};