feat(s7): DateTime (S7 DATE_AND_TIME) scalar read+write via S7.Net.Types.DateTime

This commit is contained in:
Joseph Doherty
2026-06-17 05:54:08 -04:00
parent 1e5fec2f85
commit 5db08e9e85
2 changed files with 79 additions and 15 deletions
@@ -590,7 +590,7 @@ public sealed class S7Driver
internal static int ScalarByteWidth(S7TagDefinition tag) => tag.DataType switch
{
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
S7DataType.DateTime => 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}')"),
@@ -623,8 +623,11 @@ public sealed class S7Driver
// padding past curLen — it validates curLen against the block, so no extra guard is needed.
S7DataType.String => global::S7.Net.Types.S7String.FromByteArray(block),
S7DataType.DateTime => throw new NotSupportedException(
"S7 DateTime scalar reads land in a follow-up PR"),
// S7 classic DATE_AND_TIME (DT): 8-byte BCD value (year/month/day/hour/min/sec + ms +
// day-of-week nibble). S7.Net's DateTime.FromByteArray reads the BCD fields and returns a
// System.DateTime; the DT range is 19902089 and round-trips to the millisecond. Boxed as
// System.DateTime explicitly (the cast pins the box type, consistent with the numeric arms).
S7DataType.DateTime => (object)global::S7.Net.Types.DateTime.FromByteArray(block),
_ => throw new System.IO.InvalidDataException(
$"S7 scalar Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address " +
@@ -673,7 +676,11 @@ public sealed class S7Driver
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
case S7DataType.DateTime:
throw new NotSupportedException("S7 DateTime scalar writes land in a follow-up PR");
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
// 19902089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
// System.DateTime or a parseable timestamp string).
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
default:
throw new InvalidOperationException(
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// network I/O half (<c>Plc.ReadBytesAsync</c>/<c>WriteBytesAsync</c>) has no in-process
/// fake so only the codec is unit-proven (mirrors <see cref="S7ArrayReadTests"/>).
/// String (S7 classic STRING) decode/encode is proven here via
/// <see cref="S7.Net.Types.S7String"/>; DateTime decode is still a deferred stub (T4) and
/// this file pins the NotSupportedException contract it lands against.
/// <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.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7ScalarBlockTests
@@ -296,19 +298,74 @@ public sealed class S7ScalarBlockTests
decoded.ShouldBeOfType<string>().ShouldBe("ABCDE");
}
// ── Deferred-stub contract: DateTime throws NotSupportedException ──────────────────────
// ── DecodeScalarBlock — DateTime (S7 classic DATE_AND_TIME / DT) ───────────────────────
/// <summary>Verifies DateTime decode is a deferred stub (NotSupportedException — T4).</summary>
/// <summary>Verifies an 8-byte S7 DT block decodes to its <see cref="System.DateTime"/> value
/// via <see cref="S7.Net.Types.DateTime"/> (the 8-byte BCD DT helper, not the 12-byte DTL).</summary>
[Fact]
public void DecodeScalarBlock_DateTime_throws_NotSupported()
public void DecodeScalarBlock_DateTime_reads_dt_bcd_block()
{
var tag = Tag(S7DataType.DateTime);
var block = new byte[S7Driver.ScalarByteWidth(tag)];
Should.Throw<NotSupportedException>(() => S7Driver.DecodeScalarBlock(tag, Addr(), block));
var expected = new System.DateTime(2026, 6, 17, 12, 34, 56);
// Fixture built by S7.Net's own DT encoder so the block matches the on-the-wire DT layout.
var block = global::S7.Net.Types.DateTime.ToByteArray(expected);
block.Length.ShouldBe(8);
var result = S7Driver.DecodeScalarBlock(Tag(S7DataType.DateTime), Addr(), block);
result.ShouldBeOfType<System.DateTime>().ShouldBe(expected);
}
/// <summary>Verifies DateTime encode is a deferred stub (NotSupportedException — T4).</summary>
// ── EncodeScalarBlock — DateTime ───────────────────────────────────────────────────────
/// <summary>Verifies a <see cref="System.DateTime"/> encodes to an 8-byte DT block.</summary>
[Fact]
public void EncodeScalarBlock_DateTime_throws_NotSupported()
=> Should.Throw<NotSupportedException>(() => S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), "x"));
public void EncodeScalarBlock_DateTime_writes_eight_byte_block()
{
var value = new System.DateTime(2026, 6, 17, 12, 34, 56);
var block = S7Driver.EncodeScalarBlock(Tag(S7DataType.DateTime), value);
block.Length.ShouldBe(8);
// Matches S7.Net's own DT encoder exactly (same on-the-wire bytes).
block.ShouldBe(global::S7.Net.Types.DateTime.ToByteArray(value));
}
/// <summary>Verifies a string/ISO timestamp coerces via <c>Convert.ToDateTime</c> before encoding.</summary>
[Fact]
public void EncodeScalarBlock_DateTime_coerces_string_value()
{
var tag = Tag(S7DataType.DateTime);
var block = S7Driver.EncodeScalarBlock(tag, "2026-06-17T12:34:56");
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), block);
decoded.ShouldBeOfType<System.DateTime>().ShouldBe(new System.DateTime(2026, 6, 17, 12, 34, 56));
}
/// <summary>Verifies a year outside the S7 DT range (19902089) throws — S7.Net's DT encoder
/// validates the range and raises <see cref="ArgumentOutOfRangeException"/>; we surface it.</summary>
[Theory]
[InlineData(1980)]
[InlineData(2100)]
public void EncodeScalarBlock_DateTime_out_of_range_year_throws(int year)
{
var tag = Tag(S7DataType.DateTime);
var value = new System.DateTime(year, 1, 1, 0, 0, 0);
Should.Throw<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
}
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
/// <summary>Verifies DateTime round-trips through encode→decode. S7 DT preserves full
/// millisecond precision (the 8-byte BCD packs ms-tens/hundreds), so the identity holds to
/// the millisecond — no precision loss to document below the second.</summary>
[Theory]
[InlineData(1990, 1, 1, 0, 0, 0, 0)] // DT minimum.
[InlineData(2026, 6, 17, 12, 34, 56, 0)] // no sub-second.
[InlineData(2026, 6, 17, 12, 34, 56, 789)] // milliseconds preserved.
[InlineData(2089, 12, 31, 23, 59, 59, 999)] // DT maximum, ms boundary.
public void DateTime_round_trips_to_the_millisecond(
int y, int mo, int d, int h, int mi, int s, int ms)
{
var value = new System.DateTime(y, mo, d, h, mi, s, ms);
var tag = Tag(S7DataType.DateTime);
var decoded = S7Driver.DecodeScalarBlock(tag, Addr(), S7Driver.EncodeScalarBlock(tag, value));
decoded.ShouldBeOfType<System.DateTime>().ShouldBe(value);
}
}