feat(s7): Timer/Counter read (read-only) + route Timer/Counter through buffer path

This commit is contained in:
Joseph Doherty
2026-06-17 06:03:47 -04:00
parent 5db08e9e85
commit 8cfb8e920e
2 changed files with 228 additions and 52 deletions
@@ -398,10 +398,11 @@ public sealed class S7Driver
/// <summary>
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// <b>Now empty</b> — 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 <see cref="ReinterpretRawValue"/>) as
/// the single grep target / future seam should a data type ever need re-gating at init.
/// <b>Now empty</b> — 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
/// <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
/// data type ever need re-gating at init.
/// </summary>
private static readonly HashSet<S7DataType> 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
/// <summary>
/// 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 (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) 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:
/// <list type="bullet">
/// <item>the wide/structured types (Int64/UInt64/Float64/String/DateTime), which are
/// byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>) 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;</item>
/// <item>the Timer/Counter AREAS — a Timer (<c>T{n}</c>) decodes a 2-byte S5TIME word
/// to a duration in seconds (<c>double</c>) and a Counter (<c>C{n}</c>) decodes a
/// 2-byte word to a count (<c>int</c>); both are read-only this phase. Routing them
/// here (rather than through the narrow path, which would hit the dead Float64 stub and
/// throw <c>BadNotSupported</c> on every read) closes the interim correctness hole.</item>
/// </list>
/// 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.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/>.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> excludes Timer/Counter from the seam.</param>
/// <param name="addr">Parsed address — its <see cref="S7Area"/> routes Timer/Counter into the seam regardless of <see cref="S7DataType"/>.</param>
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
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;
/// <summary>
@@ -570,7 +579,12 @@ public sealed class S7Driver
/// </summary>
private async Task<object> 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
}
/// <summary>
/// Byte width of one wide/structured scalar. Int64/UInt64/Float64/DateTime are 8 bytes;
/// an S7 STRING occupies <c>StringLength + 2</c> 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; <see cref="IsBufferType"/> gates
/// this so a non-buffer type never reaches here.
/// Byte width of one wide/structured scalar (or one Timer/Counter word).
/// <b>The parsed AREA takes precedence over the tag's <see cref="S7DataType"/></b>: a
/// Timer/Counter address is always 2 bytes (one S5TIME / counter word), even though a
/// Timer tag is typed <see cref="S7DataType.Float64"/> (which would otherwise yield 8) and
/// a Counter tag <see cref="S7DataType.Int32"/>. For DB/M/I/Q areas the width follows the
/// DataType: Int64/UInt64/Float64/DateTime are 8 bytes; an S7 STRING occupies
/// <c>StringLength + 2</c> bytes (the two-byte header carries the declared max length and
/// the current actual length). Throws for any non-buffer type — defensive;
/// <see cref="IsBufferType"/> gates this so a non-buffer type never reaches here.
/// </summary>
/// <param name="tag">Tag definition carrying the <see cref="S7DataType"/> and (for strings) <c>StringLength</c>.</param>
/// <returns>The byte width to read/write for this scalar.</returns>
internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch
/// <param name="addr">Parsed address — Timer/Counter areas force a 2-byte width regardless of <see cref="S7DataType"/>.</param>
/// <returns>The byte width to read for this scalar.</returns>
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}')"),
};
}
/// <summary>
/// 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
/// <returns>The decoded scalar value boxed as <see cref="object"/>.</returns>
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
/// <returns>The big-endian byte block to write.</returns>
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
/// <summary>
/// Maps the driver's <see cref="S7Area"/> to S7.Net's <c>DataType</c> 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 <c>Timer</c>/<c>Counter</c>
/// transport types — the <c>ScalarByteWidth</c> 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).
/// </summary>
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"),
};
/// <summary>
@@ -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}"),
};
@@ -14,8 +14,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// String (S7 classic STRING) decode/encode is proven here via
/// <see cref="S7.Net.Types.S7String"/>; DateTime (S7 classic DATE_AND_TIME / DT, 8-byte BCD)
/// decode/encode is proven via <see cref="S7.Net.Types.DateTime"/> (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 <c>double</c>) and Counter (word → <c>int</c> count)
/// decode is proven via <see cref="S7.Net.Types.Timer"/> / <see cref="S7.Net.Types.Counter"/>
/// 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 <see cref="NotSupportedException"/>.
/// </summary>
[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);
/// <summary>Verifies String width is StringLength + 2 (S7 STRING header: max-len + actual-len).</summary>
[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);
/// <summary>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.</summary>
[Fact]
public void ScalarByteWidth_Timer_is_two_despite_Float64_datatype()
=> S7Driver.ScalarByteWidth(TimerTag(), TimerAddr()).ShouldBe(2);
/// <summary>A Counter tag is typed Int32; the Counter AREA reads exactly 2 bytes (one counter word).</summary>
[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<System.DateTime>().ShouldBe(value);
}
// ── DecodeScalarBlock — Timer (S5TIME → seconds, read-only) ───────────────────────────
/// <summary>
/// Verifies a 2-byte S5TIME block decodes to a duration in SECONDS as a <c>double</c>,
/// 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
/// <see cref="S7.Net.Types.Timer.FromByteArray"/> S5TIME decode).
/// </summary>
[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<double>().ShouldBe(100.0, tolerance: 1e-9);
}
/// <summary>Verifies a sub-second S5TIME decodes correctly (base = 100 ms, BCD = 250 → 25.0 s).</summary>
[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<double>().ShouldBe(25.0, tolerance: 1e-9);
}
/// <summary>Verifies a zero S5TIME word decodes to 0.0 seconds.</summary>
[Fact]
public void DecodeScalarBlock_Timer_zero()
{
var result = S7Driver.DecodeScalarBlock(TimerTag(), TimerAddr(), new byte[] { 0x00, 0x00 });
result.ShouldBeOfType<double>().ShouldBe(0.0);
}
// ── DecodeScalarBlock — Counter (BCD/raw word → count, read-only) ──────────────────────
/// <summary>
/// Verifies a 2-byte counter block decodes to a COUNT as an <c>int</c>, routed by the
/// Counter AREA (the tag is typed Int32). S7.Net's
/// <see cref="S7.Net.Types.Counter.FromByteArray"/> reads the word big-endian; we surface
/// it as a (non-negative) <c>int</c>.
/// </summary>
[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<int>().ShouldBe(66);
}
/// <summary>Verifies a larger counter value decodes (0x0123 = 291) — pins big-endian order.</summary>
[Fact]
public void DecodeScalarBlock_Counter_decodes_big_endian()
{
var block = new byte[] { 0x01, 0x23 };
var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), block);
result.ShouldBeOfType<int>().ShouldBe(0x0123);
}
/// <summary>Verifies a zero counter decodes to 0.</summary>
[Fact]
public void DecodeScalarBlock_Counter_zero()
{
var result = S7Driver.DecodeScalarBlock(CounterTag(), CounterAddr(), new byte[] { 0x00, 0x00 });
result.ShouldBeOfType<int>().ShouldBe(0);
}
// ── EncodeScalarBlock — Timer/Counter are read-only this phase ─────────────────────────
/// <summary>Verifies a Timer write throws NotSupportedException — Timer/Counter are read-only.</summary>
[Fact]
public void EncodeScalarBlock_Timer_throws_read_only()
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(TimerTag(), 1.0));
/// <summary>Verifies a Counter write throws NotSupportedException — Timer/Counter are read-only.</summary>
[Fact]
public void EncodeScalarBlock_Counter_throws_read_only()
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(CounterTag(), 42));
}