From 5db08e9e858d841a031210c2afdfe944f118f308 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 05:54:08 -0400 Subject: [PATCH] feat(s7): DateTime (S7 DATE_AND_TIME) scalar read+write via S7.Net.Types.DateTime --- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 15 +++- .../S7ScalarBlockTests.cs | 79 ++++++++++++++++--- 2 files changed, 79 insertions(+), 15 deletions(-) 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 972aee74..f5f7f7af 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -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 1990–2089 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 + // 1990–2089 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( 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 736f5b65..b408a326 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 @@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// network I/O half (Plc.ReadBytesAsync/WriteBytesAsync) has no in-process /// fake so only the codec is unit-proven (mirrors ). /// String (S7 classic STRING) decode/encode is proven here via -/// ; DateTime decode is still a deferred stub (T4) and -/// this file pins the NotSupportedException contract it lands against. +/// ; 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. /// [Trait("Category", "Unit")] public sealed class S7ScalarBlockTests @@ -296,19 +298,74 @@ public sealed class S7ScalarBlockTests decoded.ShouldBeOfType().ShouldBe("ABCDE"); } - // ── Deferred-stub contract: DateTime throws NotSupportedException ────────────────────── + // ── DecodeScalarBlock — DateTime (S7 classic DATE_AND_TIME / DT) ─────────────────────── - /// Verifies DateTime decode is a deferred stub (NotSupportedException — T4). + /// Verifies an 8-byte S7 DT block decodes to its value + /// via (the 8-byte BCD DT helper, not the 12-byte DTL). [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(() => 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().ShouldBe(expected); } - /// Verifies DateTime encode is a deferred stub (NotSupportedException — T4). + // ── EncodeScalarBlock — DateTime ─────────────────────────────────────────────────────── + + /// Verifies a encodes to an 8-byte DT block. [Fact] - public void EncodeScalarBlock_DateTime_throws_NotSupported() - => Should.Throw(() => 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)); + } + + /// Verifies a string/ISO timestamp coerces via Convert.ToDateTime before encoding. + [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().ShouldBe(new System.DateTime(2026, 6, 17, 12, 34, 56)); + } + + /// Verifies a year outside the S7 DT range (1990–2089) throws — S7.Net's DT encoder + /// validates the range and raises ; we surface it. + [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(() => S7Driver.EncodeScalarBlock(tag, value)); + } + + // ── DateTime round-trip identity (encode → decode) ──────────────────────────────────── + + /// 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. + [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().ShouldBe(value); + } }