feat(s7): Timer/Counter read (read-only) + route Timer/Counter through buffer path
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user