feat(twincat): TIME/TOD as Double ms, DATE/DT as UTC DateTime with read decode + write encode (#306)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:24:12 -04:00
parent d898262c4a
commit 90e3fe1cf7
10 changed files with 226 additions and 32 deletions
@@ -194,18 +194,28 @@ public sealed class TwinCATArrayCodecTests
}
/// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
/// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same
/// CLR shape a scalar publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c> (<c>ARRAY [0..12] OF TIME</c>)
/// begins <c>0, 10, 20</c> ms.
/// </summary>
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_duration_elements_as_double_ms(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new double[] { 0, 10, 20 });
/// <summary>DATE / DT elements decode to UTC DateTime from seconds since 1970.</summary>
[Theory]
[InlineData(TwinCATDataType.Date)]
[InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new uint[] { 0, 10, 20 });
public void Decode_date_elements_as_utc_datetime(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 60, 0, 0, 0], new TwinCATArrayLayout(2, 4), utf8: false)
.ShouldBe(new[]
{
new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(1970, 1, 1, 0, 1, 0, DateTimeKind.Utc),
});
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[Fact]
@@ -93,8 +93,8 @@ public sealed class TwinCATDriverTests
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
// IEC TIME/DATE/DT/TOD surface as their raw UDINT counter (Driver.TwinCAT-002).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
// IEC TIME/TOD surface as Double ms, DATE/DT as DateTime (#306).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
}
/// <summary>Verifies that status mapper covers known ADS error codes.</summary>
@@ -21,17 +21,16 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
/// <summary>Verifies that IEC time types map to uint32 raw counters.</summary>
/// <summary>IEC time types surface as native values (#306): TIME/TOD as Double ms, DATE/DT as DateTime.</summary>
[Fact]
public void Iec_time_types_map_to_uint32_raw_counter()
public void Iec_time_types_map_to_native_types()
{
// Documents the contract called out in the corrected comments: TIME / DATE / DT / TOD
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan.
// The next implementer who wants to decode them needs to see this mapping is intentional.
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
// The raw counter stays reachable by declaring UDInt.
TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
}
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
@@ -0,0 +1,77 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>#306 — TIME / DATE / DT / TOD decode and encode round trips on known values.</summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTemporalCodecTests
{
private static readonly DateTime Dt = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void Dt_decodes_seconds_since_1970_to_utc()
{
var raw = (uint)(Dt - DateTime.UnixEpoch).TotalSeconds;
var v = (DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.DateTime, raw);
v.ShouldBe(Dt);
v.Kind.ShouldBe(DateTimeKind.Utc);
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, v).ShouldBe(raw);
}
[Fact]
public void Date_round_trips_and_truncates_to_day()
{
var day = new DateTime(2026, 10, 8, 0, 0, 0, DateTimeKind.Utc);
var raw = TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day);
((DateTime)TwinCATTemporalCodec.Decode(TwinCATDataType.Date, raw)).ShouldBe(day);
TwinCATTemporalCodec.Encode(TwinCATDataType.Date, day.AddHours(13)).ShouldBe(raw);
}
[Fact]
public void Tod_round_trips_milliseconds()
{
const uint ms = (12 * 3600 + 30 * 60 + 15) * 1000 + 250; // TOD#12:30:15.250
TwinCATTemporalCodec.Decode(TwinCATDataType.TimeOfDay, ms).ShouldBe(45_015_250d);
TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, 45_015_250d).ShouldBe(ms);
}
[Fact]
public void Time_round_trips_milliseconds_and_accepts_timespan()
{
const uint ms = ((1 * 60 + 2) * 60 + 3) * 1000 + 4; // T#1h2m3s4ms
TwinCATTemporalCodec.Decode(TwinCATDataType.Time, ms).ShouldBe(3_723_004d);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 3_723_004d).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, new TimeSpan(0, 1, 2, 3, 4)).ShouldBe(ms);
TwinCATTemporalCodec.Encode(TwinCATDataType.Time, 5u).ShouldBe(5u); // raw integer still writes
}
[Theory]
[InlineData(-1d)]
[InlineData(double.NaN)]
[InlineData(5_000_000_000d)]
public void Time_encode_refuses_out_of_range(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, value));
[Fact]
public void Dt_before_1970_is_refused()
=> Should.Throw<OverflowException>(() =>
TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, new DateTime(1960, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
[Fact]
public void Wrong_shape_is_a_type_mismatch()
{
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, Dt));
Should.Throw<InvalidCastException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.DateTime, TimeSpan.FromSeconds(1)));
}
[Fact]
public void Client_write_conversion_routes_temporal_types_through_the_codec()
{
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Time, 1000d).ShouldBe(1000u);
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.DateTime, Dt)
.ShouldBe((uint)(Dt - DateTime.UnixEpoch).TotalSeconds);
// Authored UDInt against a TIME symbol keeps the raw counter.
AdsTwinCATClient.ConvertForWrite(TwinCATDataType.UDInt, 1000u).ShouldBe(1000u);
}
}