feat(s7): byte-buffer codec dispatch + Int64/UInt64/LReal scalar read+write

This commit is contained in:
Joseph Doherty
2026-06-17 05:38:18 -04:00
parent 06b858eb02
commit 286be5df88
7 changed files with 453 additions and 128 deletions
@@ -499,6 +499,14 @@ public sealed class S7Driver
if (tag.ArrayCount is >= 1)
return await ReadArrayAsync(plc, tag, addr, ct).ConfigureAwait(false);
// Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime): S7.Net's string
// ReadAsync only decodes 1/2/4-byte size suffixes, so these can't go through the narrow
// path below. Read a contiguous byte block at the start byte and decode it big-endian —
// mirrors the array path's buffer read. Timer/Counter are excluded here (IsBufferType
// gates on Area) and keep using the narrow path for now (broadened in a follow-up).
if (IsBufferType(tag, addr))
return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false);
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
@@ -536,6 +544,134 @@ public sealed class S7Driver
return DecodeArrayBlock(tag, addr, block);
}
/// <summary>
/// True when this tag must be read/written through the byte-buffer codec rather than
/// S7.Net's string path. The wide/structured types (Int64/UInt64/Float64/String/DateTime)
/// are byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) and decoded from a
/// contiguous block — S7.Net's string ReadAsync only understands 1/2/4-byte size
/// suffixes, so they can't use the narrow path. Timer/Counter areas are deliberately
/// excluded (they still route through the narrow path for now); a follow-up adds them to
/// this seam. The init guard already enforces byte-addressing + the Timer→Float64 /
/// Counter→Int32 type constraints, so by the time a tag reaches here it is well-formed.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> excludes Timer/Counter from the seam.</param>
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
private static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
addr.Area is not S7Area.Timer and not S7Area.Counter
&& tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
or S7DataType.String or S7DataType.DateTime;
/// <summary>
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)</c> (a single
/// PLC round-trip), then hands the raw block to the pure <see cref="DecodeScalarBlock"/>
/// decode. Mirrors <see cref="ReadArrayAsync"/>'s shape.
/// </summary>
private async Task<object> ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct)
{
var width = ScalarByteWidth(tag);
var block = await plc.ReadBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, width, ct)
.ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null block for '{tag.Address}'");
return DecodeScalarBlock(tag, addr, block);
}
/// <summary>
/// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes;
/// an S7 STRING occupies <c>StringLength + 2</c> bytes (the two-byte header carries the
/// declared max length and the current actual length). Timer/Counter widths land in a
/// follow-up. Throws for any non-buffer type — defensive; <see cref="IsBufferType"/> gates
/// this so a non-buffer type never reaches here.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
/// <returns>The byte width to read/write for this scalar.</returns>
internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch
{
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
S7DataType.DateTime => 8,
S7DataType.String => tag.StringLength + 2,
_ => throw new InvalidOperationException(
$"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"),
};
/// <summary>
/// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR
/// value (<c>long</c> / <c>ulong</c> / <c>double</c>), boxed as <see cref="object"/>. No
/// network I/O — factored out of <see cref="ReadScalarBlockAsync"/> so the codec is
/// unit-testable against a known block without a live PLC (S7.Net ships no in-process
/// fake). Mirrors <see cref="DecodeArrayBlock"/>.
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address (carried for the error surface + the Timer/Counter seam).</param>
/// <param name="block">Raw contiguous byte block read from the PLC (length == <see cref="ScalarByteWidth"/>).</param>
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
internal static object DecodeScalarBlock(S7TagDefinition tag, S7ParsedAddress addr, byte[] block)
{
// Timer/Counter decode added in a follow-up — a branch on addr.Area prepends here.
// Each numeric arm is boxed to object explicitly: a bare switch expression would unify
// long/ulong/double to their common type (double) and box THAT, mis-typing Int64/UInt64.
return tag.DataType switch
{
S7DataType.Int64 => (object)System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(block),
S7DataType.UInt64 => System.Buffers.Binary.BinaryPrimitives.ReadUInt64BigEndian(block),
S7DataType.Float64 => System.Buffers.Binary.BinaryPrimitives.ReadDoubleBigEndian(block),
S7DataType.String => throw new NotSupportedException(
"S7 String scalar reads land in a follow-up PR"),
S7DataType.DateTime => throw new NotSupportedException(
"S7 DateTime scalar reads land in a follow-up PR"),
_ => throw new System.IO.InvalidDataException(
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}"),
};
}
/// <summary>
/// Pure encode step — turns the caller's value into the raw S7 (big-endian) byte block
/// for a wide scalar write. No network I/O — factored out of
/// <see cref="WriteScalarBlockAsync"/> so the codec is unit-testable (mirrors
/// <see cref="BoxValueForWrite"/>).
/// </summary>
/// <param name="tag">Tag definition carrying the wide <see cref="S7DataType"/>.</param>
/// <param name="value">Value to encode (coerced via <c>Convert.To*</c>).</param>
/// <returns>The big-endian byte block to write.</returns>
internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value)
{
switch (tag.DataType)
{
case S7DataType.Int64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(b, Convert.ToInt64(value));
return b;
}
case S7DataType.UInt64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteUInt64BigEndian(b, Convert.ToUInt64(value));
return b;
}
case S7DataType.Float64:
{
var b = new byte[8];
System.Buffers.Binary.BinaryPrimitives.WriteDoubleBigEndian(b, Convert.ToDouble(value));
return b;
}
case S7DataType.String:
throw new NotSupportedException("S7 String scalar writes land in a follow-up PR");
case S7DataType.DateTime:
throw new NotSupportedException("S7 DateTime scalar writes land in a follow-up PR");
default:
throw new InvalidOperationException(
$"S7 EncodeScalarBlock called for non-buffer type {tag.DataType} (tag '{tag.Name}')");
}
}
/// <summary>Width in bytes of one array element for the given access size. Bit elements are
/// byte-granular over the wire (one byte per bool), so they cost 1 byte each.</summary>
/// <param name="size">The parsed access width.</param>
@@ -770,6 +906,23 @@ public sealed class S7Driver
private async Task WriteOneAsync(Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
// Parse the address the same way ReadOneAsync does: authored tags pre-parse at init
// (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. Needed
// here so the wide-type write can byte-address the block (the narrow path below addresses
// by the raw address string instead).
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
? parsed
: S7AddressParser.Parse(tag.Address);
// Wide/structured scalar path: encode the value to a big-endian byte block and write it
// at the start byte via S7.Net's buffer-based WriteBytesAsync. Mirrors the read seam;
// the narrow string-based write below stays unchanged for 1/2/4-byte types.
if (IsBufferType(tag, addr))
{
await WriteScalarBlockAsync(plc, tag, addr, value, ct).ConfigureAwait(false);
return;
}
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
@@ -778,6 +931,19 @@ public sealed class S7Driver
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
}
/// <summary>
/// Writes a wide/structured scalar as ONE contiguous byte block via S7.Net's
/// buffer-based <c>Plc.WriteBytesAsync(DataType, db, startByteAdr, byte[] value, ct)</c>.
/// The pure <see cref="EncodeScalarBlock"/> produces the big-endian bytes; this method
/// owns only the network I/O (mirrors <see cref="ReadScalarBlockAsync"/>).
/// </summary>
private async Task WriteScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, object? value, CancellationToken ct)
{
var bytes = EncodeScalarBlock(tag, value);
await plc.WriteBytesAsync(ToS7NetArea(addr.Area), addr.DbNumber, addr.ByteOffset, bytes, ct)
.ConfigureAwait(false);
}
/// <summary>
/// Pure boxing step — converts the caller's value into the unsigned wire type that
/// S7.Net's <c>Plc.WriteAsync</c> expects for each address size (bool → bool, byte