Merge pull request '[s7] S7 — DTL/DT/S5TIME/TIME/TOD/DATE codecs' (#338) from auto/s7/PR-S7-A3 into auto/driver-gaps

This commit was merged in pull request #338.
This commit is contained in:
2026-04-25 16:40:02 -04:00
4 changed files with 784 additions and 0 deletions

View File

@@ -0,0 +1,358 @@
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 &gt; 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));
}
}

View File

@@ -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,
};

View File

@@ -123,4 +123,16 @@ public enum S7DataType
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
WChar,
DateTime,
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
Dtl,
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
DateAndTime,
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
S5Time,
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
Time,
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
TimeOfDay,
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
Date,
}

View File

@@ -0,0 +1,336 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Golden-byte unit tests for <see cref="S7DateTimeCodec"/>: 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
/// <see cref="S7StringCodecTests"/>.
/// </summary>
[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<byte>(0x07);
bytes[1].ShouldBe<byte>(0xE8);
bytes[2].ShouldBe<byte>(0x01);
bytes[3].ShouldBe<byte>(0x0F);
// 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2.
bytes[4].ShouldBe<byte>(0x02);
bytes[5].ShouldBe<byte>(0x0C);
bytes[6].ShouldBe<byte>(0x22);
bytes[7].ShouldBe<byte>(0x38);
bytes[8].ShouldBe<byte>(0x00);
bytes[9].ShouldBe<byte>(0x00);
bytes[10].ShouldBe<byte>(0x00);
bytes[11].ShouldBe<byte>(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<InvalidDataException>(() => 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<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
// Month 13 — invalid.
var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf2));
}
[Fact]
public void DecodeDtl_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(new byte[11]));
}
[Fact]
public void EncodeDtl_rejects_year_outside_spec()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
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<byte>(0x24);
bytes[1].ShouldBe<byte>(0x01);
bytes[2].ShouldBe<byte>(0x15);
bytes[3].ShouldBe<byte>(0x12);
bytes[4].ShouldBe<byte>(0x34);
bytes[5].ShouldBe<byte>(0x56);
bytes[6].ShouldBe<byte>(0x78);
bytes[7].ShouldBe<byte>(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<byte>(0x95);
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
}
[Fact]
public void DecodeDt_rejects_all_zero_uninitialized_buffer()
{
Should.Throw<InvalidDataException>(() => 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<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(buf));
}
[Fact]
public void EncodeDt_rejects_year_outside_1990_to_2089_window()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<byte>(0x01);
bytes[1].ShouldBe<byte>(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<byte>(0x39);
bytes[1].ShouldBe<byte>(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<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<ArgumentException>(() =>
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<InvalidDataException>(() => 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<ArgumentOutOfRangeException>(() =>
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<InvalidDataException>(() => S7DateTimeCodec.DecodeTod(buf));
}
[Fact]
public void EncodeTod_rejects_negative_and_overflow()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1)));
Should.Throw<ArgumentOutOfRangeException>(() =>
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<ArgumentOutOfRangeException>(() =>
S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31)));
}
[Fact]
public void DecodeDate_rejects_wrong_buffer_length()
{
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[1]));
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[3]));
}
}