From 8d91833fed4ca0824dfe1f92d30949bdea913c1f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 21:41:46 -0400 Subject: [PATCH] feat(s7): pure narrow-scalar decode from byte spans --- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 74 ++++++ .../S7SpanCodecTests.cs | 220 ++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanCodecTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 14378ca3..7ef1ba03 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -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]; + /// + /// 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 + /// 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). + /// + /// Parsed address carrying the access . + /// Byte width: Bit/Byte = 1, Word = 2, DWord = 4. + internal static int NarrowScalarWidth(S7ParsedAddress addr) => ElementByteSize(addr.Size); + + /// + /// Pure decode step — turns the tag's own slice of a contiguous big-endian block into the + /// SEMANTIC CLR value declared by its , boxed as + /// . No network I/O and no driver state: this is the span-read twin of + /// (which reinterprets the boxed value S7.Net's + /// per-tag string ReadAsync 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. + /// + /// + /// is positioned at the tag's OWN first byte — + /// slice[0] is the tag's first byte, so is + /// the caller's business and is never re-applied here. Only the leading + /// 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 + /// — and fall into the mismatch arm here so a mis-routed + /// tag fails loudly instead of decoding garbage from a truncated slice. + /// + /// Tag definition carrying the narrow . + /// Parsed address carrying the access and (for Bool) the bit position. + /// The tag's slice of the block, big-endian, at least bytes long. + /// The decoded value boxed as , with the same box type produces. + internal static object DecodeNarrowScalar(S7TagDefinition tag, S7ParsedAddress addr, ReadOnlySpan 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}"); + } + } + /// /// Pure reinterpret step — converts the boxed value that S7.Net returns (always an /// unsigned type: bool, byte, ushort, uint) into the diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanCodecTests.cs new file mode 100644 index 00000000..e204d958 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanCodecTests.cs @@ -0,0 +1,220 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Unit tests for the pure narrow-scalar SPAN codec — +/// and . +/// 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 +/// Plc.ReadBytesAsync) instead of issuing one string Plc.ReadAsync per tag. +/// The requirement is byte-for-byte parity with the string-read path, so every arm is +/// also asserted against 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 — so they +/// are only asserted to fail loudly here. +/// +[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 ──────────────────────────────────────────────────────────────── + + /// Width per access size — Bit/Byte = 1, Word = 2, DWord = 4 (matches ElementByteSize). + [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); + + /// The span width agrees with the array-element width for every narrow size. + [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 ────────────────────────────────────── + + /// + /// 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 ShouldBe + /// coerces across numeric types, so an Int16 surfaced as a boxed int 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. + /// + [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()); + } + + /// + /// PARITY: the span decode returns exactly what the string-read path + /// () returns for the same bytes, fed the boxed + /// unsigned value S7.Net's ReadAsync would have produced (DBX=bool, DBB=byte, + /// DBW=ushort, DBD=uint). This is the requirement the span planner rests on. + /// + [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 ───────────────────────────────────────────────────────────────── + + /// + /// The slice is positioned at the tag's own first byte and only the leading + /// bytes are consumed — trailing bytes belonging + /// to the NEXT tag in the block must not leak into the value. + /// + [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); + } + + /// + /// A tag mid-block decodes from its own offset slice — the caller positions the span, the + /// codec never re-applies addr.ByteOffset. + /// + [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().ShouldBe(42.0f); + } + + // ── Mismatches fail loudly ─────────────────────────────────────────────────────────── + + /// + /// A declared type that does not match the parsed access size is a config error, not a + /// silent mis-decode (mirrors 's default arm). + /// + [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( + () => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(size), bytes)); + } + + /// + /// Wide types never belong on the narrow span path (they route through + /// ); a mis-routed one must fail here rather than + /// decode garbage from a truncated slice. + /// + [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( + () => S7Driver.DecodeNarrowScalar(Tag(dt), Addr(S7Size.DWord), bytes)); + } +}