2b66cec582
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) <noreply@anthropic.com>
359 lines
16 KiB
C#
359 lines
16 KiB
C#
using System.Buffers.Binary;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="S7Driver"/> so
|
|
/// the encoding rules are unit-testable against golden byte vectors without standing
|
|
/// up a Plc instance — same pattern as <see cref="S7StringCodec"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Wire formats (all big-endian, matching S7's native byte order):
|
|
/// <list type="bullet">
|
|
/// <item>
|
|
/// <b>DTL</b> (12 bytes): year UInt16 BE / month / day / day-of-week / hour /
|
|
/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>DATE_AND_TIME (DT)</b> (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.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>S5TIME</b> (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.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>TIME</b> (Int32 ms BE): signed milliseconds. Negative durations allowed.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>TOD</b> (UInt32 ms BE): milliseconds since midnight, 0..86399999.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>DATE</b> (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168).
|
|
/// </item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <b>Uninitialized PLC bytes</b>: an all-zero DTL or DT buffer (year 0 / month 0)
|
|
/// is rejected as <see cref="InvalidDataException"/> rather than decoded as
|
|
/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading
|
|
/// valid-but-wrong timestamp.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class S7DateTimeCodec
|
|
{
|
|
// ---- DTL (12 bytes) ----
|
|
|
|
/// <summary>Wire size of an S7 DTL value.</summary>
|
|
public const int DtlSize = 12;
|
|
|
|
/// <summary>
|
|
/// Decode a 12-byte DTL buffer into a DateTime. Throws
|
|
/// <see cref="InvalidDataException"/> when the buffer is uninitialized
|
|
/// (all-zero year+month) or when components are out of range.
|
|
/// </summary>
|
|
public static DateTime DecodeDtl(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>Encode a DateTime as a 12-byte DTL buffer.</summary>
|
|
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) ----
|
|
|
|
/// <summary>Wire size of an S7 DATE_AND_TIME value.</summary>
|
|
public const int DtSize = 8;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static DateTime DecodeDt(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer.</summary>
|
|
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) ----
|
|
|
|
/// <summary>Wire size of an S7 S5TIME value.</summary>
|
|
public const int S5TimeSize = 2;
|
|
|
|
/// <summary>
|
|
/// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout:
|
|
/// <c>0000 TTBB BBBB BBBB</c> where TT is the timebase (00=10ms, 01=100ms,
|
|
/// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999).
|
|
/// </summary>
|
|
public static TimeSpan DecodeS5Time(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits
|
|
/// <paramref name="value"/> in 999 units. Rejects negative or > 9990s durations
|
|
/// and any value not a multiple of the chosen timebase.
|
|
/// </summary>
|
|
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) ----
|
|
|
|
/// <summary>Wire size of an S7 TIME value.</summary>
|
|
public const int TimeSize = 4;
|
|
|
|
/// <summary>Decode a 4-byte TIME buffer into a TimeSpan (signed milliseconds).</summary>
|
|
public static TimeSpan DecodeTime(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>Encode a TimeSpan as a 4-byte TIME (signed Int32 milliseconds, big-endian).</summary>
|
|
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) ----
|
|
|
|
/// <summary>Wire size of an S7 TIME_OF_DAY value.</summary>
|
|
public const int TodSize = 4;
|
|
|
|
/// <summary>Decode a 4-byte TOD buffer into a TimeSpan (ms since midnight).</summary>
|
|
public static TimeSpan DecodeTod(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>Encode a TimeSpan as a 4-byte TOD (UInt32 ms since midnight, big-endian).</summary>
|
|
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) ----
|
|
|
|
/// <summary>Wire size of an S7 DATE value.</summary>
|
|
public const int DateSize = 2;
|
|
|
|
/// <summary>S7 DATE epoch — 1990-01-01 (UTC-unspecified per Siemens spec).</summary>
|
|
public static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
|
|
|
/// <summary>Decode a 2-byte DATE buffer into a DateTime.</summary>
|
|
public static DateTime DecodeDate(ReadOnlySpan<byte> 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);
|
|
}
|
|
|
|
/// <summary>Encode a DateTime as a 2-byte DATE (UInt16 days since 1990-01-01, big-endian).</summary>
|
|
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 ----
|
|
|
|
/// <summary>Decode a single BCD byte (each nibble must be a decimal digit 0-9).</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Encode a 0-99 value as a single BCD byte.</summary>
|
|
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));
|
|
}
|
|
}
|