From 2b66cec582ad25145b51b65fc58932a97db682c6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 16:37:39 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20s7-a3=20=E2=80=94=20DTL/DT/S5TIME/TIME/?= =?UTF-8?q?TOD/DATE=20codecs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds S7DateTimeCodec static class implementing the six Siemens S7 date/time wire formats: - DTL (12 bytes): UInt16 BE year + month/day/dow/h/m/s + UInt32 BE nanos - DATE_AND_TIME (8 bytes BCD): yy/mm/dd/hh/mm/ss + 3-digit BCD ms + dow - S5TIME (16 bits): 2-bit timebase + 3-digit BCD count → TimeSpan - TIME (Int32 ms BE, signed) → TimeSpan, allows negative durations - TOD (UInt32 ms BE, 0..86399999) → TimeSpan since midnight - DATE (UInt16 BE days since 1990-01-01) → DateTime Mirrors the S7StringCodec pattern from PR-S7-A2 — codecs operate on raw byte spans so each format can be locked with golden-byte unit tests without a live PLC. New S7DataType members (Dtl, DateAndTime, S5Time, Time, TimeOfDay, Date) are wired into S7Driver.ReadOneAsync/WriteOneAsync via byte-level ReadBytesAsync/WriteBytesAsync calls — S7.Net's string-keyed Read/Write overloads have no syntax for these widths. Uninitialized PLC buffers (all-zero year+month for DTL/DT) reject as InvalidDataException → BadOutOfRange to operators, rather than decoding as year-0001 garbage. S5TIME / TIME / TOD surface as Int32 ms (DriverDataType has no Duration); DTL / DT / DATE surface as DriverDataType.DateTime. Test coverage: 30 new golden-vector + round-trip + rejection tests, including the all-zero buffer rejection paths and BCD-nibble validation. Build clean, 115/115 S7 tests pass. Closes #289 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../S7DateTimeCodec.cs | 358 ++++++++++++++++++ src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 78 ++++ .../S7DriverOptions.cs | 12 + .../S7DateTimeCodecTests.cs | 336 ++++++++++++++++ 4 files changed, 784 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs new file mode 100644 index 0000000..068226f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs @@ -0,0 +1,358 @@ +using System.Buffers.Binary; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// Byte-level codecs for the six Siemens S7 date/time-shaped types: DTL, DATE_AND_TIME +/// (DT), S5TIME, TIME, TIME_OF_DAY (TOD), DATE. Pulled out of so +/// the encoding rules are unit-testable against golden byte vectors without standing +/// up a Plc instance — same pattern as . +/// +/// +/// Wire formats (all big-endian, matching S7's native byte order): +/// +/// +/// DTL (12 bytes): year UInt16 BE / month / day / day-of-week / hour / +/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554. +/// +/// +/// DATE_AND_TIME (DT) (8 bytes BCD): year-since-1990 / month / day / hour / +/// minute / second (1 BCD byte each) + ms (3 BCD digits packed in 1.5 bytes) + +/// day-of-week (1 BCD digit, 1=Sunday..7=Saturday). Years 90-99 → 1990-1999; +/// years 00-89 → 2000-2089. +/// +/// +/// S5TIME (16 bits): bits 15..14 reserved (0), bits 13..12 timebase +/// (00=10ms, 01=100ms, 10=1s, 11=10s), bits 11..0 = 3-digit BCD count (0-999). +/// Total range 0..9990s. +/// +/// +/// TIME (Int32 ms BE): signed milliseconds. Negative durations allowed. +/// +/// +/// TOD (UInt32 ms BE): milliseconds since midnight, 0..86399999. +/// +/// +/// DATE (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168). +/// +/// +/// +/// Uninitialized PLC bytes: an all-zero DTL or DT buffer (year 0 / month 0) +/// is rejected as rather than decoded as +/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading +/// valid-but-wrong timestamp. +/// +/// +public static class S7DateTimeCodec +{ + // ---- DTL (12 bytes) ---- + + /// Wire size of an S7 DTL value. + public const int DtlSize = 12; + + /// + /// Decode a 12-byte DTL buffer into a DateTime. Throws + /// when the buffer is uninitialized + /// (all-zero year+month) or when components are out of range. + /// + public static DateTime DecodeDtl(ReadOnlySpan bytes) + { + if (bytes.Length != DtlSize) + throw new InvalidDataException($"S7 DTL expected {DtlSize} bytes, got {bytes.Length}"); + + int year = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(0, 2)); + int month = bytes[2]; + int day = bytes[3]; + // bytes[4] = day-of-week (1=Sunday..7=Saturday); ignored on read — the .NET + // DateTime carries its own and the PLC value can be inconsistent on uninit data. + int hour = bytes[5]; + int minute = bytes[6]; + int second = bytes[7]; + uint nanos = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4)); + + if (year == 0 && month == 0 && day == 0) + throw new InvalidDataException("S7 DTL is uninitialized (all-zero year/month/day)"); + if (year is < 1970 or > 2554) + throw new InvalidDataException($"S7 DTL year {year} out of range 1970..2554"); + if (month is < 1 or > 12) + throw new InvalidDataException($"S7 DTL month {month} out of range 1..12"); + if (day is < 1 or > 31) + throw new InvalidDataException($"S7 DTL day {day} out of range 1..31"); + if (hour > 23) throw new InvalidDataException($"S7 DTL hour {hour} out of range 0..23"); + if (minute > 59) throw new InvalidDataException($"S7 DTL minute {minute} out of range 0..59"); + if (second > 59) throw new InvalidDataException($"S7 DTL second {second} out of range 0..59"); + if (nanos > 999_999_999) + throw new InvalidDataException($"S7 DTL nanoseconds {nanos} out of range 0..999999999"); + + // .NET DateTime resolution is 100 ns ticks (1 tick = 100 ns). + var dt = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified); + return dt.AddTicks(nanos / 100); + } + + /// Encode a DateTime as a 12-byte DTL buffer. + public static byte[] EncodeDtl(DateTime value) + { + if (value.Year is < 1970 or > 2554) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DTL year must be 1970..2554"); + + var buf = new byte[DtlSize]; + BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)value.Year); + buf[2] = (byte)value.Month; + buf[3] = (byte)value.Day; + // S7 day-of-week: 1=Sunday..7=Saturday. .NET DayOfWeek: Sunday=0..Saturday=6. + buf[4] = (byte)((int)value.DayOfWeek + 1); + buf[5] = (byte)value.Hour; + buf[6] = (byte)value.Minute; + buf[7] = (byte)value.Second; + + // Sub-second portion → nanoseconds. 1 tick = 100 ns, so ticks % 10_000_000 gives + // the fractional second in ticks; multiply by 100 for nanoseconds. + long fracTicks = value.Ticks % TimeSpan.TicksPerSecond; + uint nanos = (uint)(fracTicks * 100); + BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8, 4), nanos); + return buf; + } + + // ---- DATE_AND_TIME / DT (8 bytes BCD) ---- + + /// Wire size of an S7 DATE_AND_TIME value. + public const int DtSize = 8; + + /// + /// Decode an 8-byte DATE_AND_TIME (BCD) buffer into a DateTime. Year encoding: + /// 90..99 → 1990..1999, 00..89 → 2000..2089 (per Siemens spec). + /// + public static DateTime DecodeDt(ReadOnlySpan bytes) + { + if (bytes.Length != DtSize) + throw new InvalidDataException($"S7 DATE_AND_TIME expected {DtSize} bytes, got {bytes.Length}"); + + int yy = FromBcd(bytes[0]); + int month = FromBcd(bytes[1]); + int day = FromBcd(bytes[2]); + int hour = FromBcd(bytes[3]); + int minute = FromBcd(bytes[4]); + int second = FromBcd(bytes[5]); + + // bytes[6] and high nibble of bytes[7] = milliseconds (3 BCD digits). + // Low nibble of bytes[7] = day-of-week (1=Sunday..7=Saturday); ignored on read. + int msHigh = (bytes[6] >> 4) & 0xF; + int msMid = bytes[6] & 0xF; + int msLow = (bytes[7] >> 4) & 0xF; + if (msHigh > 9 || msMid > 9 || msLow > 9) + throw new InvalidDataException($"S7 DT ms BCD digits invalid: {msHigh:X}{msMid:X}{msLow:X}"); + int ms = msHigh * 100 + msMid * 10 + msLow; + + if (yy == 0 && month == 0 && day == 0) + throw new InvalidDataException("S7 DT is uninitialized (all-zero year/month/day)"); + + int year = yy >= 90 ? 1900 + yy : 2000 + yy; + if (month is < 1 or > 12) throw new InvalidDataException($"S7 DT month {month} out of range 1..12"); + if (day is < 1 or > 31) throw new InvalidDataException($"S7 DT day {day} out of range 1..31"); + if (hour > 23) throw new InvalidDataException($"S7 DT hour {hour} out of range 0..23"); + if (minute > 59) throw new InvalidDataException($"S7 DT minute {minute} out of range 0..59"); + if (second > 59) throw new InvalidDataException($"S7 DT second {second} out of range 0..59"); + + return new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Unspecified); + } + + /// Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer. + public static byte[] EncodeDt(DateTime value) + { + if (value.Year is < 1990 or > 2089) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE_AND_TIME year must be 1990..2089"); + + int yy = value.Year >= 2000 ? value.Year - 2000 : value.Year - 1900; + int ms = value.Millisecond; + // S7 day-of-week: 1=Sunday..7=Saturday. + int dow = (int)value.DayOfWeek + 1; + + var buf = new byte[DtSize]; + buf[0] = ToBcd(yy); + buf[1] = ToBcd(value.Month); + buf[2] = ToBcd(value.Day); + buf[3] = ToBcd(value.Hour); + buf[4] = ToBcd(value.Minute); + buf[5] = ToBcd(value.Second); + // ms = 3 digits packed across bytes [6] (high+mid nibbles) and [7] high nibble. + buf[6] = (byte)(((ms / 100) << 4) | ((ms / 10) % 10)); + buf[7] = (byte)((((ms % 10) & 0xF) << 4) | (dow & 0xF)); + return buf; + } + + // ---- S5TIME (16 bits BCD) ---- + + /// Wire size of an S7 S5TIME value. + public const int S5TimeSize = 2; + + /// + /// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout: + /// 0000 TTBB BBBB BBBB where TT is the timebase (00=10ms, 01=100ms, + /// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999). + /// + public static TimeSpan DecodeS5Time(ReadOnlySpan bytes) + { + if (bytes.Length != S5TimeSize) + throw new InvalidDataException($"S7 S5TIME expected {S5TimeSize} bytes, got {bytes.Length}"); + + int hi = bytes[0]; + int lo = bytes[1]; + int tb = (hi >> 4) & 0x3; + int d2 = hi & 0xF; + int d1 = (lo >> 4) & 0xF; + int d0 = lo & 0xF; + if (d2 > 9 || d1 > 9 || d0 > 9) + throw new InvalidDataException($"S7 S5TIME BCD digits invalid: {d2:X}{d1:X}{d0:X}"); + + int count = d2 * 100 + d1 * 10 + d0; + long unitMs = tb switch + { + 0 => 10L, + 1 => 100L, + 2 => 1000L, + 3 => 10_000L, + _ => throw new InvalidDataException($"S7 S5TIME timebase {tb} invalid"), + }; + return TimeSpan.FromMilliseconds(count * unitMs); + } + + /// + /// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits + /// in 999 units. Rejects negative or > 9990s durations + /// and any value not a multiple of the chosen timebase. + /// + public static byte[] EncodeS5Time(TimeSpan value) + { + if (value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME must be non-negative"); + long totalMs = (long)value.TotalMilliseconds; + if (totalMs > 9_990_000) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME max is 9990 seconds"); + + int tb; + long unit; + if (totalMs <= 9_990 && totalMs % 10 == 0) { tb = 0; unit = 10; } + else if (totalMs <= 99_900 && totalMs % 100 == 0) { tb = 1; unit = 100; } + else if (totalMs <= 999_000 && totalMs % 1000 == 0) { tb = 2; unit = 1_000; } + else if (totalMs % 10_000 == 0) { tb = 3; unit = 10_000; } + else + throw new ArgumentException( + $"S7 S5TIME duration {value} cannot be represented in any timebase without truncation", + nameof(value)); + + long count = totalMs / unit; + if (count > 999) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME count exceeds 999 in chosen timebase"); + + int d2 = (int)(count / 100); + int d1 = (int)((count / 10) % 10); + int d0 = (int)(count % 10); + + var buf = new byte[2]; + buf[0] = (byte)(((tb & 0x3) << 4) | (d2 & 0xF)); + buf[1] = (byte)(((d1 & 0xF) << 4) | (d0 & 0xF)); + return buf; + } + + // ---- TIME (Int32 ms BE) ---- + + /// Wire size of an S7 TIME value. + public const int TimeSize = 4; + + /// Decode a 4-byte TIME buffer into a TimeSpan (signed milliseconds). + public static TimeSpan DecodeTime(ReadOnlySpan bytes) + { + if (bytes.Length != TimeSize) + throw new InvalidDataException($"S7 TIME expected {TimeSize} bytes, got {bytes.Length}"); + int ms = BinaryPrimitives.ReadInt32BigEndian(bytes); + return TimeSpan.FromMilliseconds(ms); + } + + /// Encode a TimeSpan as a 4-byte TIME (signed Int32 milliseconds, big-endian). + public static byte[] EncodeTime(TimeSpan value) + { + long totalMs = (long)value.TotalMilliseconds; + if (totalMs is < int.MinValue or > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TIME exceeds Int32 ms range"); + var buf = new byte[TimeSize]; + BinaryPrimitives.WriteInt32BigEndian(buf, (int)totalMs); + return buf; + } + + // ---- TOD / TIME_OF_DAY (UInt32 ms BE, 0..86399999) ---- + + /// Wire size of an S7 TIME_OF_DAY value. + public const int TodSize = 4; + + /// Decode a 4-byte TOD buffer into a TimeSpan (ms since midnight). + public static TimeSpan DecodeTod(ReadOnlySpan bytes) + { + if (bytes.Length != TodSize) + throw new InvalidDataException($"S7 TOD expected {TodSize} bytes, got {bytes.Length}"); + uint ms = BinaryPrimitives.ReadUInt32BigEndian(bytes); + if (ms > 86_399_999) + throw new InvalidDataException($"S7 TOD value {ms} exceeds 86399999 ms (one day)"); + return TimeSpan.FromMilliseconds(ms); + } + + /// Encode a TimeSpan as a 4-byte TOD (UInt32 ms since midnight, big-endian). + public static byte[] EncodeTod(TimeSpan value) + { + if (value < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD must be non-negative"); + long totalMs = (long)value.TotalMilliseconds; + if (totalMs > 86_399_999) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD max is 86399999 ms (23:59:59.999)"); + var buf = new byte[TodSize]; + BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)totalMs); + return buf; + } + + // ---- DATE (UInt16 BE, days since 1990-01-01) ---- + + /// Wire size of an S7 DATE value. + public const int DateSize = 2; + + /// S7 DATE epoch — 1990-01-01 (UTC-unspecified per Siemens spec). + public static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); + + /// Decode a 2-byte DATE buffer into a DateTime. + public static DateTime DecodeDate(ReadOnlySpan bytes) + { + if (bytes.Length != DateSize) + throw new InvalidDataException($"S7 DATE expected {DateSize} bytes, got {bytes.Length}"); + ushort days = BinaryPrimitives.ReadUInt16BigEndian(bytes); + return DateEpoch.AddDays(days); + } + + /// Encode a DateTime as a 2-byte DATE (UInt16 days since 1990-01-01, big-endian). + public static byte[] EncodeDate(DateTime value) + { + var days = (value.Date - DateEpoch).TotalDays; + if (days is < 0 or > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE must be 1990-01-01..2168-06-06"); + var buf = new byte[DateSize]; + BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)days); + return buf; + } + + // ---- BCD helpers ---- + + /// Decode a single BCD byte (each nibble must be a decimal digit 0-9). + private static int FromBcd(byte b) + { + int hi = (b >> 4) & 0xF; + int lo = b & 0xF; + if (hi > 9 || lo > 9) + throw new InvalidDataException($"S7 BCD byte 0x{b:X2} has non-decimal nibble"); + return hi * 10 + lo; + } + + /// Encode a 0-99 value as a single BCD byte. + private static byte ToBcd(int value) + { + if (value is < 0 or > 99) + throw new ArgumentOutOfRangeException(nameof(value), value, "BCD byte source must be 0..99"); + return (byte)(((value / 10) << 4) | (value % 10)); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index a942680..5a441bb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -270,6 +270,48 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) } } + // Date/time-shaped types (DTL/DT/S5TIME/TIME/TOD/DATE): S7.Net has no native size + // suffix for any of these, so the driver issues a raw byte read at the address's + // ByteOffset and decodes via S7DateTimeCodec. All require byte-addressing — bit- + // access against a date/time tag is a config bug worth surfacing as a hard error. + if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time + or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date) + { + if (addr.Size == S7Size.Bit) + throw new System.IO.InvalidDataException( + $"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " + + $"parsed as bit-access; date/time types require byte-addressing"); + + int size = tag.DataType switch + { + S7DataType.Dtl => S7DateTimeCodec.DtlSize, + S7DataType.DateAndTime => S7DateTimeCodec.DtSize, + S7DataType.S5Time => S7DateTimeCodec.S5TimeSize, + S7DataType.Time => S7DateTimeCodec.TimeSize, + S7DataType.TimeOfDay => S7DateTimeCodec.TodSize, + S7DataType.Date => S7DateTimeCodec.DateSize, + _ => throw new InvalidOperationException(), + }; + + var b = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, size, ct).ConfigureAwait(false); + if (b is null || b.Length != size) + throw new System.IO.InvalidDataException( + $"S7.Net returned {b?.Length ?? 0} bytes for {tag.DataType} '{tag.Address}', expected {size}"); + + return tag.DataType switch + { + S7DataType.Dtl => S7DateTimeCodec.DecodeDtl(b), + S7DataType.DateAndTime => S7DateTimeCodec.DecodeDt(b), + // S5TIME/TIME/TOD surface as Int32 ms — DriverDataType has no Duration type; + // OPC UA clients see a millisecond integer matching the IEC-1131 convention. + S7DataType.S5Time => (int)S7DateTimeCodec.DecodeS5Time(b).TotalMilliseconds, + S7DataType.Time => (int)S7DateTimeCodec.DecodeTime(b).TotalMilliseconds, + S7DataType.TimeOfDay => (int)S7DateTimeCodec.DecodeTod(b).TotalMilliseconds, + S7DataType.Date => S7DateTimeCodec.DecodeDate(b), + _ => throw new InvalidOperationException(), + }; + } + // 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an // 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE. if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64) @@ -402,6 +444,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) return; } + // Date/time-shaped types: encode via S7DateTimeCodec and push as raw bytes. S5TIME / + // TIME / TOD accept an integer-ms input (matching the read surface); DTL / DT / DATE + // accept a DateTime. ArgumentException from the codec bubbles to BadInternalError. + if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time + or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date) + { + var addr = _parsedByName[tag.Name]; + if (addr.Size == S7Size.Bit) + throw new InvalidOperationException( + $"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " + + $"parsed as bit-access; date/time types require byte-addressing"); + if (value is null) + throw new ArgumentNullException(nameof(value)); + + byte[] payload = tag.DataType switch + { + S7DataType.Dtl => S7DateTimeCodec.EncodeDtl(Convert.ToDateTime(value)), + S7DataType.DateAndTime => S7DateTimeCodec.EncodeDt(Convert.ToDateTime(value)), + S7DataType.S5Time => S7DateTimeCodec.EncodeS5Time(value is TimeSpan ts1 ? ts1 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))), + S7DataType.Time => S7DateTimeCodec.EncodeTime(value is TimeSpan ts2 ? ts2 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))), + S7DataType.TimeOfDay => S7DateTimeCodec.EncodeTod(value is TimeSpan ts3 ? ts3 : TimeSpan.FromMilliseconds(Convert.ToInt64(value))), + S7DataType.Date => S7DateTimeCodec.EncodeDate(Convert.ToDateTime(value)), + _ => throw new InvalidOperationException(), + }; + await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false); + return; + } + // 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit // the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a // BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path. @@ -491,6 +561,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId) S7DataType.Char => DriverDataType.String, S7DataType.WChar => DriverDataType.String, S7DataType.DateTime => DriverDataType.DateTime, + S7DataType.Dtl => DriverDataType.DateTime, + S7DataType.DateAndTime => DriverDataType.DateTime, + S7DataType.Date => DriverDataType.DateTime, + // S5TIME/TIME/TOD have no Duration type in DriverDataType — surface as Int32 ms + // (matching the IEC-1131 representation). + S7DataType.S5Time => DriverDataType.Int32, + S7DataType.Time => DriverDataType.Int32, + S7DataType.TimeOfDay => DriverDataType.Int32, _ => DriverDataType.Int32, }; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs index 567144d..6b1cd26 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverOptions.cs @@ -123,4 +123,16 @@ public enum S7DataType /// S7 WCHAR: two bytes UTF-16 big-endian. WChar, DateTime, + /// S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554. + Dtl, + /// S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089. + DateAndTime, + /// S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms. + S5Time, + /// S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed). + Time, + /// S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms. + TimeOfDay, + /// S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime. + Date, } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs new file mode 100644 index 0000000..2034df2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs @@ -0,0 +1,336 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Golden-byte unit tests for : DTL / DATE_AND_TIME / +/// S5TIME / TIME / TOD / DATE encode + decode round-trips, plus the +/// uninitialized-PLC-buffer rejection paths. These tests don't touch S7.Net — the +/// codec operates on raw byte spans, same testing pattern as +/// . +/// +[Trait("Category", "Unit")] +public sealed class S7DateTimeCodecTests +{ + // -------- DTL (12 bytes) -------- + + [Fact] + public void EncodeDtl_emits_be_year_then_components_then_be_nanoseconds() + { + // 2024-01-15 12:34:56.000 → year=0x07E8, mon=01, day=0F, dow=Mon=2, + // hour=0x0C, min=0x22, sec=0x38, nanos=0x00000000 + var dt = new DateTime(2024, 1, 15, 12, 34, 56, DateTimeKind.Unspecified); + var bytes = S7DateTimeCodec.EncodeDtl(dt); + + bytes.Length.ShouldBe(12); + bytes[0].ShouldBe(0x07); + bytes[1].ShouldBe(0xE8); + bytes[2].ShouldBe(0x01); + bytes[3].ShouldBe(0x0F); + // 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2. + bytes[4].ShouldBe(0x02); + bytes[5].ShouldBe(0x0C); + bytes[6].ShouldBe(0x22); + bytes[7].ShouldBe(0x38); + bytes[8].ShouldBe(0x00); + bytes[9].ShouldBe(0x00); + bytes[10].ShouldBe(0x00); + bytes[11].ShouldBe(0x00); + } + + [Fact] + public void DecodeDtl_round_trips_encode_with_nanosecond_precision() + { + // 250 ms = 250_000_000 ns = 0x0EE6B280 + var dt = new DateTime(2024, 1, 15, 12, 34, 56, 250, DateTimeKind.Unspecified); + var bytes = S7DateTimeCodec.EncodeDtl(dt); + var decoded = S7DateTimeCodec.DecodeDtl(bytes); + decoded.ShouldBe(dt); + } + + [Fact] + public void DecodeDtl_rejects_all_zero_uninitialized_buffer() + { + // Brand-new DB — PLC hasn't written a real value yet. Must surface as a hard + // error, not as year-0001 garbage. + var buf = new byte[12]; + Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf)); + } + + [Fact] + public void DecodeDtl_rejects_out_of_range_components() + { + // Year 1969 → out of S7 DTL spec range (1970..2554). + var buf = new byte[] { 0x07, 0xB1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 }; + Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf)); + + // Month 13 — invalid. + var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 }; + Should.Throw(() => S7DateTimeCodec.DecodeDtl(buf2)); + } + + [Fact] + public void DecodeDtl_rejects_wrong_buffer_length() + { + Should.Throw(() => S7DateTimeCodec.DecodeDtl(new byte[11])); + } + + [Fact] + public void EncodeDtl_rejects_year_outside_spec() + { + Should.Throw(() => + S7DateTimeCodec.EncodeDtl(new DateTime(1969, 1, 1))); + } + + // -------- DATE_AND_TIME (DT, 8 bytes BCD) -------- + + [Fact] + public void EncodeDt_emits_bcd_components() + { + // 2024-01-15 12:34:56.789, dow Monday → S7 dow = 2. + // BCD: yy=24→0x24, mon=01→0x01, day=15→0x15, hh=12→0x12, mm=34→0x34, ss=56→0x56 + // ms=789 → bytes[6]=0x78, bytes[7] high nibble=0x9, low nibble=dow=2 → 0x92. + var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified); + var bytes = S7DateTimeCodec.EncodeDt(dt); + + bytes.Length.ShouldBe(8); + bytes[0].ShouldBe(0x24); + bytes[1].ShouldBe(0x01); + bytes[2].ShouldBe(0x15); + bytes[3].ShouldBe(0x12); + bytes[4].ShouldBe(0x34); + bytes[5].ShouldBe(0x56); + bytes[6].ShouldBe(0x78); + bytes[7].ShouldBe(0x92); + } + + [Fact] + public void DecodeDt_round_trips_post_2000() + { + var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified); + var bytes = S7DateTimeCodec.EncodeDt(dt); + S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt); + } + + [Fact] + public void DecodeDt_round_trips_pre_2000_using_90_to_99_year_window() + { + // 1995-06-30 — yy=95 → year window says 1995. + var dt = new DateTime(1995, 6, 30, 0, 0, 0, DateTimeKind.Unspecified); + var bytes = S7DateTimeCodec.EncodeDt(dt); + bytes[0].ShouldBe(0x95); + S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt); + } + + [Fact] + public void DecodeDt_rejects_all_zero_uninitialized_buffer() + { + Should.Throw(() => S7DateTimeCodec.DecodeDt(new byte[8])); + } + + [Fact] + public void DecodeDt_rejects_invalid_bcd_nibble() + { + // Month 0x1A — high nibble OK but low nibble 0xA is not a decimal digit. + var buf = new byte[] { 0x24, 0x1A, 0x15, 0x12, 0x34, 0x56, 0x00, 0x02 }; + Should.Throw(() => S7DateTimeCodec.DecodeDt(buf)); + } + + [Fact] + public void EncodeDt_rejects_year_outside_1990_to_2089_window() + { + Should.Throw(() => + S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31))); + Should.Throw(() => + S7DateTimeCodec.EncodeDt(new DateTime(2090, 1, 1))); + } + + // -------- S5TIME (16 bits BCD) -------- + + [Fact] + public void EncodeS5Time_one_second_uses_10ms_timebase_with_count_100() + { + // T#1S = 1000 ms = 100 × 10 ms (timebase 0). Layout: 0000 00BB BBBB BBBB + // tb=00, count=100=BCD 0x100 → byte0=0x01, byte1=0x00. + var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(1)); + bytes.Length.ShouldBe(2); + bytes[0].ShouldBe(0x01); + bytes[1].ShouldBe(0x00); + } + + [Fact] + public void EncodeS5Time_picks_10s_timebase_for_long_durations() + { + // T#9990S = 9990 s = 999 × 10 s (timebase 11). count=999 BCD 0x999. + // byte0 = (3 << 4) | 9 = 0x39, byte1 = 0x99. + var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(9990)); + bytes[0].ShouldBe(0x39); + bytes[1].ShouldBe(0x99); + } + + [Fact] + public void DecodeS5Time_round_trips_each_timebase() + { + foreach (var ts in new[] + { + TimeSpan.FromMilliseconds(10), // tb=00 (10 ms) + TimeSpan.FromMilliseconds(500), // tb=00 (10 ms × 50) + TimeSpan.FromSeconds(1), // tb=00 (10 ms × 100) + TimeSpan.FromSeconds(60), // tb=01 (100 ms × 600)? No — 60s/100ms=600 > 999, picks 1s. + TimeSpan.FromMinutes(10), // tb=10 (1 s × 600) + TimeSpan.FromMinutes(30), // tb=11 (10 s × 180) + }) + { + var bytes = S7DateTimeCodec.EncodeS5Time(ts); + S7DateTimeCodec.DecodeS5Time(bytes).ShouldBe(ts); + } + } + + [Fact] + public void EncodeS5Time_rejects_negative_or_too_large() + { + Should.Throw(() => + S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1))); + Should.Throw(() => + S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(10_000))); + } + + [Fact] + public void EncodeS5Time_rejects_durations_not_representable_in_any_timebase() + { + // 7 ms — no timebase divides cleanly, would lose precision. + Should.Throw(() => + S7DateTimeCodec.EncodeS5Time(TimeSpan.FromMilliseconds(7))); + } + + [Fact] + public void DecodeS5Time_rejects_invalid_bcd() + { + // Hi nibble of byte0 has timebase=00 but digit nibble 0xA — illegal. + var buf = new byte[] { 0x0A, 0x00 }; + Should.Throw(() => S7DateTimeCodec.DecodeS5Time(buf)); + } + + // -------- TIME (Int32 ms BE, signed) -------- + + [Fact] + public void EncodeTime_emits_signed_int32_be_milliseconds() + { + // T#1S = 1000 ms = 0x000003E8. + var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(1)); + bytes.ShouldBe(new byte[] { 0x00, 0x00, 0x03, 0xE8 }); + } + + [Fact] + public void EncodeTime_supports_negative_durations() + { + // -1000 ms = 0xFFFFFC18 (two's complement Int32). + var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(-1)); + bytes.ShouldBe(new byte[] { 0xFF, 0xFF, 0xFC, 0x18 }); + } + + [Fact] + public void DecodeTime_round_trips_positive_and_negative() + { + foreach (var ts in new[] + { + TimeSpan.Zero, + TimeSpan.FromMilliseconds(1), + TimeSpan.FromHours(24), + TimeSpan.FromMilliseconds(-12345), + }) + { + S7DateTimeCodec.DecodeTime(S7DateTimeCodec.EncodeTime(ts)).ShouldBe(ts); + } + } + + [Fact] + public void EncodeTime_rejects_overflow() + { + Should.Throw(() => + S7DateTimeCodec.EncodeTime(TimeSpan.FromMilliseconds((long)int.MaxValue + 1))); + } + + // -------- TOD (UInt32 ms BE, 0..86399999) -------- + + [Fact] + public void EncodeTod_emits_unsigned_int32_be_milliseconds() + { + // 12:00:00.000 = 12 × 3_600_000 = 43_200_000 ms = 0x0293_2E00. + var bytes = S7DateTimeCodec.EncodeTod(new TimeSpan(12, 0, 0)); + bytes.ShouldBe(new byte[] { 0x02, 0x93, 0x2E, 0x00 }); + } + + [Fact] + public void DecodeTod_round_trips_midnight_and_max() + { + S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(TimeSpan.Zero)).ShouldBe(TimeSpan.Zero); + + // 23:59:59.999 — last valid TOD. + var max = new TimeSpan(0, 23, 59, 59, 999); + S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(max)).ShouldBe(max); + } + + [Fact] + public void DecodeTod_rejects_value_at_or_above_one_day() + { + // 86_400_000 ms = 0x0526_5C00 — exactly 24 h, must reject. + var buf = new byte[] { 0x05, 0x26, 0x5C, 0x00 }; + Should.Throw(() => S7DateTimeCodec.DecodeTod(buf)); + } + + [Fact] + public void EncodeTod_rejects_negative_and_overflow() + { + Should.Throw(() => + S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1))); + Should.Throw(() => + S7DateTimeCodec.EncodeTod(TimeSpan.FromHours(24))); + } + + // -------- DATE (UInt16 BE, days since 1990-01-01) -------- + + [Fact] + public void EncodeDate_emits_be_uint16_days_since_epoch() + { + // 1990-01-01 → 0 days → 0x0000. + S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 1)).ShouldBe(new byte[] { 0x00, 0x00 }); + + // 1990-01-02 → 1 day → 0x0001. + S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 2)).ShouldBe(new byte[] { 0x00, 0x01 }); + + // 2024-01-15 → 12_432 days = 0x3090. + var bytes = S7DateTimeCodec.EncodeDate(new DateTime(2024, 1, 15)); + bytes.ShouldBe(new byte[] { 0x30, 0x90 }); + } + + [Fact] + public void DecodeDate_round_trips_encode() + { + foreach (var d in new[] + { + new DateTime(1990, 1, 1), + new DateTime(2000, 2, 29), + new DateTime(2024, 1, 15), + new DateTime(2099, 12, 31), + }) + { + S7DateTimeCodec.DecodeDate(S7DateTimeCodec.EncodeDate(d)).ShouldBe(d); + } + } + + [Fact] + public void EncodeDate_rejects_pre_epoch() + { + Should.Throw(() => + S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31))); + } + + [Fact] + public void DecodeDate_rejects_wrong_buffer_length() + { + Should.Throw(() => S7DateTimeCodec.DecodeDate(new byte[1])); + Should.Throw(() => S7DateTimeCodec.DecodeDate(new byte[3])); + } +}