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}"),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user