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));
}