feat(s7): pure narrow-scalar decode from byte spans

This commit is contained in:
Joseph Doherty
2026-08-15 21:41:46 -04:00
parent 6ff64ca067
commit 8d91833fed
2 changed files with 294 additions and 0 deletions
@@ -986,6 +986,80 @@ public sealed class S7Driver
((uint)block[offset] << 24) | ((uint)block[offset + 1] << 16)
| ((uint)block[offset + 2] << 8) | block[offset + 3];
/// <summary>
/// Byte width one narrow scalar (Bit/Byte/Word/DWord) occupies inside a contiguous block —
/// the stride a span planner uses to size and slice a group read. Delegates to
/// <see cref="ElementByteSize"/> so the scalar and array paths can never disagree about a
/// size's width (a Bit costs a whole byte: S7 block reads are byte-granular).
/// </summary>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/>.</param>
/// <returns>Byte width: Bit/Byte = 1, Word = 2, DWord = 4.</returns>
internal static int NarrowScalarWidth(S7ParsedAddress addr) => ElementByteSize(addr.Size);
/// <summary>
/// Pure decode step — turns the tag's own slice of a contiguous big-endian block into the
/// SEMANTIC CLR value declared by its <see cref="S7DataType"/>, boxed as
/// <see cref="object"/>. No network I/O and no driver state: this is the span-read twin of
/// <see cref="ReinterpretRawValue"/> (which reinterprets the boxed value S7.Net's
/// per-tag string <c>ReadAsync</c> returns), and it deliberately mirrors that method's
/// sign/width/conversion semantics arm for arm so a tag reads identically whether it was
/// served by one wire request of its own or by one block read shared with its neighbours.
/// </summary>
/// <remarks>
/// <paramref name="slice"/> is positioned at the tag's OWN first byte —
/// <c>slice[0]</c> is the tag's first byte, so <see cref="S7ParsedAddress.ByteOffset"/> is
/// the caller's business and is never re-applied here. Only the leading
/// <see cref="NarrowScalarWidth"/> bytes are consumed; a longer slice (the rest of the
/// block) is fine. Wide/structured types (Int64/UInt64/Float64/String/DateTime) and the
/// Timer/Counter areas are NOT narrow — they route through
/// <see cref="DecodeScalarBlock"/> — and fall into the mismatch arm here so a mis-routed
/// tag fails loudly instead of decoding garbage from a truncated slice.
/// </remarks>
/// <param name="tag">Tag definition carrying the narrow <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address carrying the access <see cref="S7Size"/> and (for Bool) the bit position.</param>
/// <param name="slice">The tag's slice of the block, big-endian, at least <see cref="NarrowScalarWidth"/> bytes long.</param>
/// <returns>The decoded value boxed as <see cref="object"/>, with the same box type <see cref="ReinterpretRawValue"/> produces.</returns>
internal static object DecodeNarrowScalar(S7TagDefinition tag, S7ParsedAddress addr, ReadOnlySpan<byte> slice)
{
// Each arm boxes explicitly: a bare switch expression would unify bool/byte/ushort/short/
// uint/int/float to a common type and box THAT, mis-typing every narrow tag (the same trap
// DecodeScalarBlock documents). The box type is part of the contract — the publish pipeline
// and the OPC UA variable's DataType both key off it.
switch (tag.DataType, addr.Size)
{
// DBX / M{n}.{b}: S7.Net's string read returns the already-extracted bool. From a block
// we extract it ourselves at the parsed bit position (0-7, guaranteed by the parser).
case (S7DataType.Bool, S7Size.Bit):
return (object)((slice[0] & (1 << addr.BitOffset)) != 0);
// DBB: the whole byte, unsigned (S7.Net boxes a byte).
case (S7DataType.Byte, S7Size.Byte):
return (object)slice[0];
// DBW: one big-endian word. UInt16 is the raw word; Int16 is the SAME bits reinterpreted
// unchecked (0x8000 => -32768), exactly as ReinterpretRawValue casts S7.Net's ushort.
case (S7DataType.UInt16, S7Size.Word):
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice);
case (S7DataType.Int16, S7Size.Word):
return (object)unchecked((short)System.Buffers.Binary.BinaryPrimitives.ReadUInt16BigEndian(slice));
// DBD: one big-endian dword. UInt32 raw; Int32 the same bits unchecked; Float32 the same
// bits reinterpreted as IEEE-754 single (never a numeric conversion).
case (S7DataType.UInt32, S7Size.DWord):
return (object)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice);
case (S7DataType.Int32, S7Size.DWord):
return (object)unchecked((int)System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
case (S7DataType.Float32, S7Size.DWord):
return (object)BitConverter.UInt32BitsToSingle(
System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(slice));
default:
throw new System.IO.InvalidDataException(
$"S7 narrow-span Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
$"'{tag.Address}' parsed as Size={addr.Size}");
}
}
/// <summary>
/// Pure reinterpret step — converts the boxed value that S7.Net returns (always an
/// unsigned type: <c>bool</c>, <c>byte</c>, <c>ushort</c>, <c>uint</c>) into the