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 f5f7f7af..c286ca0f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -398,10 +398,11 @@ public sealed class S7Driver /// /// S7DataType members that the read/write helpers throw NotSupportedException for. - /// Now empty — Phase 4d (S7 wide types + Timer/Counter) unblocks Int64, UInt64, - /// Float64, String, and DateTime at init; the codec wires them through in follow-up - /// tasks. Kept here (rather than reflecting over ) as - /// the single grep target / future seam should a data type ever need re-gating at init. + /// Now empty — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64, + /// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read + /// through it on AREA. Kept here (rather than reflecting over + /// ) as the single grep target / future seam should a + /// data type ever need re-gating at init. /// private static readonly HashSet UnimplementedDataTypes = new(); @@ -499,11 +500,12 @@ 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). + // Wide/structured scalar path (Int64/UInt64/Float64/String/DateTime) AND the Timer/Counter + // areas: S7.Net's string ReadAsync only decodes 1/2/4-byte size suffixes, so neither family + // can go through the narrow path below. Read a contiguous byte block and decode it — + // mirrors the array path's buffer read. Timer/Counter route here on AREA (IsBufferType), + // so a Timer/Float64 tag reads its 2-byte S5TIME word here rather than falling through to + // the dead Float64 stub (which would surface BadNotSupported on every read). if (IsBufferType(tag, addr)) return await ReadScalarBlockAsync(plc, tag, addr, ct).ConfigureAwait(false); @@ -546,20 +548,27 @@ public sealed class S7Driver /// /// 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 (DBB/MB/IB/QB) 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. + /// S7.Net's string path. Two families route here: + /// + /// the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are + /// byte-anchored (DBB/MB/IB/QB) 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; + /// the Timer/Counter AREAS — a Timer (T{n}) decodes a 2-byte S5TIME word + /// to a duration in seconds (double) and a Counter (C{n}) decodes a + /// 2-byte word to a count (int); both are read-only this phase. Routing them + /// here (rather than through the narrow path, which would hit the dead Float64 stub and + /// throw BadNotSupported on every read) closes the interim correctness hole. + /// + /// 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. /// /// Tag definition carrying the . - /// Parsed address — its excludes Timer/Counter from the seam. + /// Parsed address — its routes Timer/Counter into the seam regardless of . /// true if the tag routes through the byte-buffer codec. 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 + addr.Area is S7Area.Timer or S7Area.Counter + || tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.String or S7DataType.DateTime; /// @@ -570,7 +579,12 @@ public sealed class S7Driver /// private async Task ReadScalarBlockAsync(Plc plc, S7TagDefinition tag, S7ParsedAddress addr, CancellationToken ct) { - var width = ScalarByteWidth(tag); + var width = ScalarByteWidth(tag, addr); + // For DB/M/I/Q, addr.ByteOffset is the start BYTE and `width` the byte count. For + // Timer/Counter, addr.ByteOffset is the timer/counter NUMBER (db stays 0) and width is 2: + // S7.Net's Timer/Counter transport size makes the request "count" word a per-2-byte unit, + // so passing 2 reads exactly one timer/counter's 2-byte block (matching S7.Net's own + // VarType.Timer/Counter path, which sets byteLength = varCount × 2). See ScalarByteWidth. 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}'"); @@ -579,22 +593,36 @@ public sealed class S7Driver } /// - /// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes; - /// an S7 STRING occupies StringLength + 2 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; gates - /// this so a non-buffer type never reaches here. + /// Byte width of one wide/structured scalar (or one Timer/Counter word). + /// The parsed AREA takes precedence over the tag's : a + /// Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a + /// Timer tag is typed (which would otherwise yield 8) and + /// a Counter tag . For DB/M/I/Q areas the width follows the + /// DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies + /// StringLength + 2 bytes (the two-byte header carries the declared max length and + /// the current actual length). Throws for any non-buffer type — defensive; + /// gates this so a non-buffer type never reaches here. /// /// Tag definition carrying the and (for strings) StringLength. - /// The byte width to read/write for this scalar. - internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch + /// Parsed address — Timer/Counter areas force a 2-byte width regardless of . + /// The byte width to read for this scalar. + internal static int ScalarByteWidth(S7TagDefinition tag, S7ParsedAddress addr) { - S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8, - S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL). - S7DataType.String => tag.StringLength + 2, - _ => throw new InvalidOperationException( - $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"), - }; + // Area precedence: Timer (S5TIME) and Counter are a single 16-bit word on the wire. The + // Timer tag's Float64 DataType describes the DECODED value (seconds), not the read width, + // so it must NOT drive an 8-byte read — that would mis-frame the timer word. + if (addr.Area is S7Area.Timer or S7Area.Counter) + return 2; + + return tag.DataType switch + { + S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8, + S7DataType.DateTime => 8, // 8 = S7 DATE_AND_TIME/DT BCD (S7.Net.Types.DateTime; the 8-byte DT, not 12-byte DTL). + S7DataType.String => tag.StringLength + 2, + _ => throw new InvalidOperationException( + $"S7 ScalarByteWidth called for non-buffer type {tag.DataType} (tag '{tag.Name}')"), + }; + } /// /// Pure decode step — turns a raw S7 (big-endian) byte block into the wide scalar's CLR @@ -609,7 +637,18 @@ public sealed class S7Driver /// The decoded scalar value boxed as . 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. + // AREA precedence first (before the DataType switch): a Timer (T{n}) decodes its 2-byte + // S5TIME word to a duration in SECONDS via S7.Net's Timer.FromByteArray (returns double); + // a Counter (C{n}) decodes its 2-byte word to a COUNT via Counter.FromByteArray (returns + // ushort, surfaced as a non-negative int to match the tag's Int32 type). Routing on Area + // here is essential: a Timer tag is typed Float64, so letting it fall to the Float64 arm + // below would ReadDoubleBigEndian over a 2-byte block = garbage/throw. Boxed explicitly so + // the box type is exactly double / int (not the switch-unified common type). + if (addr.Area is S7Area.Timer) + return (object)global::S7.Net.Types.Timer.FromByteArray(block); + if (addr.Area is S7Area.Counter) + return (object)(int)global::S7.Net.Types.Counter.FromByteArray(block); + // 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 @@ -646,6 +685,18 @@ public sealed class S7Driver /// The big-endian byte block to write. internal static byte[] EncodeScalarBlock(S7TagDefinition tag, object? value) { + // Timer/Counter are READ-ONLY this phase. A Timer tag is typed Float64 and a Counter tag + // Int32, so the DataType switch below would otherwise hand them a (wrong, 8/wide-byte) + // encode arm — guard on the parsed AREA first and refuse the write. The address parses to + // an S7Area without I/O; TryParse keeps a malformed address from masking the read-only + // contract (a bad address falls through to the DataType arms and is caught upstream). + if (S7AddressParser.TryParse(tag.Address, out var addr) + && addr.Area is S7Area.Timer or S7Area.Counter) + { + throw new NotSupportedException( + $"S7 Timer/Counter writes are read-only this phase (tag '{tag.Name}' at '{tag.Address}')."); + } + switch (tag.DataType) { case S7DataType.Int64: @@ -703,8 +754,10 @@ public sealed class S7Driver /// /// Maps the driver's to S7.Net's DataType for the - /// buffer-based block read. Timer/Counter are rejected at init so they never reach the - /// array path. + /// buffer-based block read. Timer/Counter map to S7.Net's Timer/Counter + /// transport types — the ScalarByteWidth 2-byte read uses these to read one + /// timer/counter word (the array path never reaches Timer/Counter: wide-type arrays are + /// rejected at init and Timer/Counter are always scalar). /// private static S7NetDataType ToS7NetArea(S7Area area) => area switch { @@ -712,8 +765,9 @@ public sealed class S7Driver S7Area.Memory => S7NetDataType.Memory, S7Area.Input => S7NetDataType.Input, S7Area.Output => S7NetDataType.Output, - _ => throw new NotSupportedException( - $"S7 area {area} is not supported for array block reads (Timer/Counter are rejected at init)"), + S7Area.Timer => S7NetDataType.Timer, + S7Area.Counter => S7NetDataType.Counter, + _ => throw new NotSupportedException($"S7 area {area} has no S7.Net DataType mapping"), }; /// @@ -832,11 +886,16 @@ public sealed class S7Driver (S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32), (S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32), - (S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"), - (S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"), - (S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"), - (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"), + // Unreachable defensive backstop — every wide type (Int64/UInt64/Float64/String/ + // DateTime) AND every Timer/Counter tag routes through the byte-buffer path + // (IsBufferType → ReadScalarBlockAsync → DecodeScalarBlock) before ReinterpretRawValue + // is ever consulted. Kept (not deleted) so a future regression that mis-routes a wide + // type to the narrow path fails loudly here instead of silently mis-decoding. + (S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"), + (S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"), + (S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"), + (S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"), + (S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (DecodeScalarBlock)"), _ => throw new System.IO.InvalidDataException( $"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " + @@ -980,11 +1039,14 @@ public sealed class S7Driver S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)), S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)), - S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"), - S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"), - S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"), - 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"), + // Unreachable defensive backstop — every wide type routes through the byte-buffer write + // path (IsBufferType → WriteScalarBlockAsync → EncodeScalarBlock) before BoxValueForWrite + // is consulted. Kept (not deleted) so a mis-route fails loudly rather than mis-encoding. + S7DataType.Int64 => throw new NotSupportedException("S7 Int64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), + S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), + S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), + S7DataType.String => throw new NotSupportedException("S7 STRING is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), + S7DataType.DateTime => throw new NotSupportedException("S7 DateTime is unreachable here — wide types route through the byte-buffer path (EncodeScalarBlock)"), _ => throw new InvalidOperationException($"Unknown S7DataType {dataType}"), }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs index b408a326..db40a0b8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ScalarBlockTests.cs @@ -14,8 +14,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// String (S7 classic STRING) decode/encode is proven here via /// ; DateTime (S7 classic DATE_AND_TIME / DT, 8-byte BCD) /// decode/encode is proven via (the 8-byte DT helper — -/// NOT DTL). Timer/Counter decode/encode are still deferred stubs (T5) and this file pins the -/// NotSupportedException contract they land against. +/// NOT DTL). Timer (S5TIME → seconds double) and Counter (word → int count) +/// decode is proven via / +/// and is routed by the parsed AREA (so a Timer/Float64 tag reads 2 bytes, not 8); both are +/// read-only this phase, so their encode throws . /// [Trait("Category", "Unit")] public sealed class S7ScalarBlockTests @@ -29,6 +31,21 @@ public sealed class S7ScalarBlockTests private static S7ParsedAddress Addr() => new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0); + // Timer / Counter addresses: the parser stores the timer/counter NUMBER in ByteOffset, + // DbNumber 0, Size Word (see S7AddressParser.ParseTimerOrCounter). A Timer tag is typed + // Float64 (decoded to seconds) and a Counter tag Int32 (decoded to count) — T1 guard-c. + private static S7TagDefinition TimerTag(int number = 5, bool writable = false) => + new("TimerTag", $"T{number}", S7DataType.Float64, Writable: writable); + + private static S7ParsedAddress TimerAddr(int number = 5) => + new(S7Area.Timer, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0); + + private static S7TagDefinition CounterTag(int number = 3, bool writable = false) => + new("CounterTag", $"C{number}", S7DataType.Int32, Writable: writable); + + private static S7ParsedAddress CounterAddr(int number = 3) => + new(S7Area.Counter, DbNumber: 0, S7Size.Word, ByteOffset: number, BitOffset: 0); + // S7 is big-endian: most-significant byte first. private static byte[] BeUInt64(ulong v) { @@ -47,12 +64,24 @@ public sealed class S7ScalarBlockTests [InlineData(S7DataType.Float64, 8)] [InlineData(S7DataType.DateTime, 8)] public void ScalarByteWidth_fixed_width_types(S7DataType dt, int expected) - => S7Driver.ScalarByteWidth(Tag(dt)).ShouldBe(expected); + => S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(expected); /// Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len). [Fact] public void ScalarByteWidth_String_is_length_plus_two() - => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10)).ShouldBe(12); + => S7Driver.ScalarByteWidth(Tag(S7DataType.String, stringLength: 10), Addr()).ShouldBe(12); + + /// Headline correctness assertion: a Timer tag is typed Float64 (which would otherwise + /// yield width 8), but the Timer AREA must take precedence and read exactly 2 bytes (one S5TIME + /// word). Without area-precedence the codec would read 8 bytes and mis-frame the timer. + [Fact] + public void ScalarByteWidth_Timer_is_two_despite_Float64_datatype() + => S7Driver.ScalarByteWidth(TimerTag(), TimerAddr()).ShouldBe(2); + + /// A Counter tag is typed Int32; the Counter AREA reads exactly 2 bytes (one counter word). + [Fact] + public void ScalarByteWidth_Counter_is_two() + => S7Driver.ScalarByteWidth(CounterTag(), CounterAddr()).ShouldBe(2); // ── DecodeScalarBlock — Int64 ───────────────────────────────────────────────────────── @@ -368,4 +397,89 @@ public sealed class S7ScalarBlockTests var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value)); decoded.ShouldBeOfType().ShouldBe(value); } + + // ── DecodeScalarBlock — Timer (S5TIME → seconds, read-only) ─────────────────────────── + + /// + /// Verifies a 2-byte S5TIME block decodes to a duration in SECONDS as a double, + /// routed by the Timer AREA (NOT the tag's Float64 DataType — that would mis-read the + /// 2-byte block as an 8-byte LReal). The fixture is a hand-built S5TIME word: the high + /// nibble of byte 0 carries the 2-bit time base (0=10 ms, 1=100 ms, 2=1 s, 3=10 s), the + /// low nibble of byte 0 + the two nibbles of byte 1 carry a 3-digit BCD value. Here + /// base bits = 2 (×1 s) and BCD = 100 → 100.0 seconds (matches S7.Net's + /// S5TIME decode). + /// + [Fact] + public void DecodeScalarBlock_Timer_decodes_s5time_to_seconds() + { + // [0x21,0x00]: byte0 high nibble 0x2 = base bits (×1 s), byte0 low nibble 0x1 = BCD hundreds, + // byte1 0x00 = BCD tens+ones → value 100 × 1 s = 100.0 s. + var block = new byte[] { 0x21, 0x00 }; + var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block); + result.ShouldBeOfType().ShouldBe(100.0, tolerance: 1e-9); + } + + /// Verifies a sub-second S5TIME decodes correctly (base = 100 ms, BCD = 250 → 25.0 s). + [Fact] + public void DecodeScalarBlock_Timer_decodes_fractional_base() + { + // [0x12,0x50]: base bits 0x1 (×0.1 s), BCD 250 → 250 × 0.1 s = 25.0 s. + var block = new byte[] { 0x12, 0x50 }; + var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), block); + result.ShouldBeOfType().ShouldBe(25.0, tolerance: 1e-9); + } + + /// Verifies a zero S5TIME word decodes to 0.0 seconds. + [Fact] + public void DecodeScalarBlock_Timer_zero() + { + var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), new byte[] { 0x00, 0x00 }); + result.ShouldBeOfType().ShouldBe(0.0); + } + + // ── DecodeScalarBlock — Counter (BCD/raw word → count, read-only) ────────────────────── + + /// + /// Verifies a 2-byte counter block decodes to a COUNT as an int, routed by the + /// Counter AREA (the tag is typed Int32). S7.Net's + /// reads the word big-endian; we surface + /// it as a (non-negative) int. + /// + [Fact] + public void DecodeScalarBlock_Counter_decodes_to_int_count() + { + // Big-endian word 0x0042 = 66. + var block = new byte[] { 0x00, 0x42 }; + var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block); + result.ShouldBeOfType().ShouldBe(66); + } + + /// Verifies a larger counter value decodes (0x0123 = 291) — pins big-endian order. + [Fact] + public void DecodeScalarBlock_Counter_decodes_big_endian() + { + var block = new byte[] { 0x01, 0x23 }; + var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block); + result.ShouldBeOfType().ShouldBe(0x0123); + } + + /// Verifies a zero counter decodes to 0. + [Fact] + public void DecodeScalarBlock_Counter_zero() + { + var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), new byte[] { 0x00, 0x00 }); + result.ShouldBeOfType().ShouldBe(0); + } + + // ── EncodeScalarBlock — Timer/Counter are read-only this phase ───────────────────────── + + /// Verifies a Timer write throws NotSupportedException — Timer/Counter are read-only. + [Fact] + public void EncodeScalarBlock_Timer_throws_read_only() + => Should.Throw(() => S7Driver.EncodeScalarBlock(TimerTag(), 1.0)); + + /// Verifies a Counter write throws NotSupportedException — Timer/Counter are read-only. + [Fact] + public void EncodeScalarBlock_Counter_throws_read_only() + => Should.Throw(() => S7Driver.EncodeScalarBlock(CounterTag(), 42)); }