feat(s7): pure narrow-scalar decode from byte spans
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the pure narrow-scalar SPAN codec —
|
||||
/// <see cref="S7Driver.DecodeNarrowScalar"/> and <see cref="S7Driver.NarrowScalarWidth"/>.
|
||||
/// These decode a Bit/Byte/Word/DWord scalar out of a contiguous big-endian byte span (the
|
||||
/// block a future span-planner will read once per group with a single
|
||||
/// <c>Plc.ReadBytesAsync</c>) instead of issuing one string <c>Plc.ReadAsync</c> per tag.
|
||||
/// The requirement is <b>byte-for-byte parity with the string-read path</b>, so every arm is
|
||||
/// also asserted against <see cref="S7Driver.ReinterpretRawValue"/> fed the exact boxed value
|
||||
/// S7.Net's string read would have returned for the same bytes (bool / byte / ushort / uint).
|
||||
/// Wide types (Int64/UInt64/Float64/String/DateTime) and the Timer/Counter areas are NOT part
|
||||
/// of the narrow path — they route through <see cref="S7Driver.DecodeScalarBlock"/> — so they
|
||||
/// are only asserted to fail loudly here.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7SpanCodecTests
|
||||
{
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The address string is only used in error messages; the decode routes on DataType + Size.
|
||||
private static S7TagDefinition Tag(S7DataType dt) => new("SpanTag", "DB1.DBW0", dt);
|
||||
|
||||
private static S7ParsedAddress Addr(S7Size size, int bitOffset = 0) =>
|
||||
new(S7Area.DataBlock, DbNumber: 1, size, ByteOffset: 0, BitOffset: bitOffset);
|
||||
|
||||
// ── NarrowScalarWidth ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Width per access size — Bit/Byte = 1, Word = 2, DWord = 4 (matches ElementByteSize).</summary>
|
||||
[Theory]
|
||||
[InlineData(S7Size.Bit, 1)]
|
||||
[InlineData(S7Size.Byte, 1)]
|
||||
[InlineData(S7Size.Word, 2)]
|
||||
[InlineData(S7Size.DWord, 4)]
|
||||
public void NarrowScalarWidth_matches_the_access_size(S7Size size, int expected)
|
||||
=> S7Driver.NarrowScalarWidth(Addr(size)).ShouldBe(expected);
|
||||
|
||||
/// <summary>The span width agrees with the array-element width for every narrow size.</summary>
|
||||
[Theory]
|
||||
[InlineData(S7Size.Bit)]
|
||||
[InlineData(S7Size.Byte)]
|
||||
[InlineData(S7Size.Word)]
|
||||
[InlineData(S7Size.DWord)]
|
||||
public void NarrowScalarWidth_agrees_with_ElementByteSize(S7Size size)
|
||||
=> S7Driver.NarrowScalarWidth(Addr(size)).ShouldBe(S7Driver.ElementByteSize(size));
|
||||
|
||||
// ── DecodeNarrowScalar: the type × bytes matrix ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Known big-endian byte patterns decode to the exact CLR value AND the exact CLR box type.
|
||||
/// The runtime type is asserted SEPARATELY and deliberately: Shouldly's <c>ShouldBe</c>
|
||||
/// coerces across numeric types, so an <c>Int16</c> surfaced as a boxed <c>int</c> passes
|
||||
/// the value assertion alone (verified by falsifying the Int16 arm) — and the box type is
|
||||
/// the contract, since the publish pipeline and the OPC UA variable's DataType key off it.
|
||||
/// Covers both sign edges of every signed width.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
// Bool — bit extracted at BitOffset, set and clear, at both ends of the byte.
|
||||
[InlineData(new byte[] { 0x01 }, S7DataType.Bool, S7Size.Bit, 0, true)]
|
||||
[InlineData(new byte[] { 0xFE }, S7DataType.Bool, S7Size.Bit, 0, false)]
|
||||
[InlineData(new byte[] { 0x80 }, S7DataType.Bool, S7Size.Bit, 7, true)]
|
||||
[InlineData(new byte[] { 0x7F }, S7DataType.Bool, S7Size.Bit, 7, false)]
|
||||
[InlineData(new byte[] { 0x08 }, S7DataType.Bool, S7Size.Bit, 3, true)]
|
||||
[InlineData(new byte[] { 0xF7 }, S7DataType.Bool, S7Size.Bit, 3, false)]
|
||||
[InlineData(new byte[] { 0xFF }, S7DataType.Bool, S7Size.Bit, 5, true)]
|
||||
[InlineData(new byte[] { 0x00 }, S7DataType.Bool, S7Size.Bit, 0, false)]
|
||||
// Byte — the whole first byte, unsigned.
|
||||
[InlineData(new byte[] { 0x00 }, S7DataType.Byte, S7Size.Byte, 0, (byte)0)]
|
||||
[InlineData(new byte[] { 0xAB }, S7DataType.Byte, S7Size.Byte, 0, (byte)0xAB)]
|
||||
[InlineData(new byte[] { 0xFF }, S7DataType.Byte, S7Size.Byte, 0, (byte)0xFF)]
|
||||
// UInt16 / Int16 — the SAME big-endian word, two semantic types (the sign edge).
|
||||
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.UInt16, S7Size.Word, 0, (ushort)32768)]
|
||||
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.Int16, S7Size.Word, 0, (short)-32768)]
|
||||
[InlineData(new byte[] { 0x7F, 0xFF }, S7DataType.UInt16, S7Size.Word, 0, (ushort)32767)]
|
||||
[InlineData(new byte[] { 0x7F, 0xFF }, S7DataType.Int16, S7Size.Word, 0, (short)32767)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.UInt16, S7Size.Word, 0, (ushort)65535)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.Int16, S7Size.Word, 0, (short)-1)]
|
||||
[InlineData(new byte[] { 0x01, 0x02 }, S7DataType.UInt16, S7Size.Word, 0, (ushort)0x0102)]
|
||||
[InlineData(new byte[] { 0x01, 0x02 }, S7DataType.Int16, S7Size.Word, 0, (short)0x0102)]
|
||||
// UInt32 / Int32 — the SAME big-endian dword, two semantic types (the sign edge).
|
||||
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.UInt32, S7Size.DWord, 0, 2147483648u)]
|
||||
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.Int32, S7Size.DWord, 0, int.MinValue)]
|
||||
[InlineData(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF }, S7DataType.UInt32, S7Size.DWord, 0, 2147483647u)]
|
||||
[InlineData(new byte[] { 0x7F, 0xFF, 0xFF, 0xFF }, S7DataType.Int32, S7Size.DWord, 0, int.MaxValue)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, S7DataType.UInt32, S7Size.DWord, 0, 4294967295u)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, S7DataType.Int32, S7Size.DWord, 0, -1)]
|
||||
[InlineData(new byte[] { 0x00, 0x00, 0x01, 0x02 }, S7DataType.UInt32, S7Size.DWord, 0, 258u)]
|
||||
[InlineData(new byte[] { 0x00, 0x00, 0x01, 0x02 }, S7DataType.Int32, S7Size.DWord, 0, 258)]
|
||||
// Float32 — IEEE-754 single from the big-endian dword.
|
||||
[InlineData(new byte[] { 0x42, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 42.0f)]
|
||||
[InlineData(new byte[] { 0xC2, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, -42.0f)]
|
||||
[InlineData(new byte[] { 0x00, 0x00, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 0.0f)]
|
||||
[InlineData(new byte[] { 0x3F, 0x80, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0, 1.0f)]
|
||||
public void DecodeNarrowScalar_decodes_known_big_endian_patterns(
|
||||
byte[] bytes, S7DataType dt, S7Size size, int bitOffset, object expected)
|
||||
{
|
||||
var actual = S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size, bitOffset), bytes);
|
||||
|
||||
actual.ShouldBe(expected);
|
||||
actual.GetType().ShouldBe(expected.GetType());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PARITY: the span decode returns exactly what the string-read path
|
||||
/// (<see cref="S7Driver.ReinterpretRawValue"/>) returns for the same bytes, fed the boxed
|
||||
/// unsigned value S7.Net's <c>ReadAsync</c> would have produced (DBX=bool, DBB=byte,
|
||||
/// DBW=ushort, DBD=uint). This is the requirement the span planner rests on.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 0x80 }, S7DataType.Bool, S7Size.Bit, 7)]
|
||||
[InlineData(new byte[] { 0x7F }, S7DataType.Bool, S7Size.Bit, 7)]
|
||||
[InlineData(new byte[] { 0x01 }, S7DataType.Bool, S7Size.Bit, 0)]
|
||||
[InlineData(new byte[] { 0xAB }, S7DataType.Byte, S7Size.Byte, 0)]
|
||||
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.UInt16, S7Size.Word, 0)]
|
||||
[InlineData(new byte[] { 0x80, 0x00 }, S7DataType.Int16, S7Size.Word, 0)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF }, S7DataType.Int16, S7Size.Word, 0)]
|
||||
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.UInt32, S7Size.DWord, 0)]
|
||||
[InlineData(new byte[] { 0x80, 0x00, 0x00, 0x00 }, S7DataType.Int32, S7Size.DWord, 0)]
|
||||
[InlineData(new byte[] { 0x42, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0)]
|
||||
[InlineData(new byte[] { 0xC2, 0x28, 0x00, 0x00 }, S7DataType.Float32, S7Size.DWord, 0)]
|
||||
public void DecodeNarrowScalar_matches_the_string_read_path(
|
||||
byte[] bytes, S7DataType dt, S7Size size, int bitOffset)
|
||||
{
|
||||
var tag = Tag(dt);
|
||||
var addr = Addr(size, bitOffset);
|
||||
|
||||
// What S7.Net's string ReadAsync would have boxed for these exact wire bytes.
|
||||
object raw = size switch
|
||||
{
|
||||
S7Size.Bit => (bytes[0] & (1 << bitOffset)) != 0,
|
||||
S7Size.Byte => bytes[0],
|
||||
S7Size.Word => (ushort)((bytes[0] << 8) | bytes[1]),
|
||||
_ => ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3],
|
||||
};
|
||||
|
||||
var fromSpan = S7Driver.DecodeNarrowScalar(tag, addr, bytes);
|
||||
var fromString = S7Driver.ReinterpretRawValue(tag, addr, raw);
|
||||
|
||||
fromSpan.ShouldBe(fromString);
|
||||
// Value parity is not enough — ShouldBe coerces numerics, so the box type is asserted too
|
||||
// (an Int16 tag published as a boxed int would change its OPC UA DataType).
|
||||
fromSpan.GetType().ShouldBe(fromString.GetType());
|
||||
}
|
||||
|
||||
// ── Span positioning ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The slice is positioned at the tag's own first byte and only the leading
|
||||
/// <see cref="S7Driver.NarrowScalarWidth"/> bytes are consumed — trailing bytes belonging
|
||||
/// to the NEXT tag in the block must not leak into the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DecodeNarrowScalar_reads_only_the_leading_width_of_a_longer_slice()
|
||||
{
|
||||
// A 6-byte "block": [word 0x0102][dword 0x7FFFFFFF trailing garbage after it].
|
||||
byte[] block = [0x01, 0x02, 0x7F, 0xFF, 0xFF, 0xFF];
|
||||
|
||||
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Int16), Addr(S7Size.Word), block)
|
||||
.ShouldBe((short)0x0102);
|
||||
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Byte), Addr(S7Size.Byte), block)
|
||||
.ShouldBe((byte)0x01);
|
||||
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Bool), Addr(S7Size.Bit, 0), block)
|
||||
.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag mid-block decodes from its own offset slice — the caller positions the span, the
|
||||
/// codec never re-applies <c>addr.ByteOffset</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DecodeNarrowScalar_decodes_from_a_mid_block_slice()
|
||||
{
|
||||
byte[] block = [0xDE, 0xAD, 0x42, 0x28, 0x00, 0x00, 0xBE, 0xEF];
|
||||
|
||||
// The Float32 lives at block byte 2 — the address's own ByteOffset is irrelevant here.
|
||||
var addr = new S7ParsedAddress(S7Area.DataBlock, DbNumber: 1, S7Size.DWord, ByteOffset: 2, BitOffset: 0);
|
||||
|
||||
S7Driver.DecodeNarrowScalar(Tag(S7DataType.Float32), addr, block.AsSpan(2))
|
||||
.ShouldBeOfType<float>().ShouldBe(42.0f);
|
||||
}
|
||||
|
||||
// ── Mismatches fail loudly ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A declared type that does not match the parsed access size is a config error, not a
|
||||
/// silent mis-decode (mirrors <see cref="S7Driver.ReinterpretRawValue"/>'s default arm).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Int16, S7Size.DWord)]
|
||||
[InlineData(S7DataType.Float32, S7Size.Word)]
|
||||
[InlineData(S7DataType.Bool, S7Size.Byte)]
|
||||
[InlineData(S7DataType.Byte, S7Size.Word)]
|
||||
public void DecodeNarrowScalar_rejects_a_type_size_mismatch(S7DataType dt, S7Size size)
|
||||
{
|
||||
byte[] bytes = [0x00, 0x00, 0x00, 0x00];
|
||||
Should.Throw<System.IO.InvalidDataException>(
|
||||
() => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size), bytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wide types never belong on the narrow span path (they route through
|
||||
/// <see cref="S7Driver.DecodeScalarBlock"/>); a mis-routed one must fail here rather than
|
||||
/// decode garbage from a truncated slice.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Int64)]
|
||||
[InlineData(S7DataType.UInt64)]
|
||||
[InlineData(S7DataType.Float64)]
|
||||
[InlineData(S7DataType.String)]
|
||||
[InlineData(S7DataType.DateTime)]
|
||||
public void DecodeNarrowScalar_rejects_wide_types(S7DataType dt)
|
||||
{
|
||||
byte[] bytes = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
Should.Throw<System.IO.InvalidDataException>(
|
||||
() => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(S7Size.DWord), bytes));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user