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)); } }