diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md
index 3a883607..2d1fab00 100644
--- a/docs/drivers/TwinCAT.md
+++ b/docs/drivers/TwinCAT.md
@@ -135,6 +135,23 @@ found against a real TwinCAT 2 PLC:
registers, and the probe-tick retry re-checks it after a PLC program change.
Reads go through the Beckhoff symbol cache, which converts numeric values by
the PLC's own type, so only the string-kind rule applies there.
+- **IEC temporal types publish native values (#306, behaviour change).** `TIME` and
+ `TOD` (TIME_OF_DAY) publish **`Double` milliseconds** (`DriverDataType.Float64`);
+ `DATE` and `DT` (DATE_AND_TIME) publish a **UTC `DateTime`** decoded from the
+ UDINT seconds-since-1970 counter (`DriverDataType.DateTime`). They used to
+ publish the raw counter as `UInt32`. Reads, notifications, array elements and
+ browse/discovery all agree (`TwinCATTemporalCodec`). No new `DriverDataType`
+ member was added (`Duration` would touch every switch in Core, the node
+ manager, historian mapper and editors); a duration is a Double ms, which is
+ what OPC UA `Duration` is on the wire. `LTIME`/`LDT` are not in the driver's
+ type surface and are still unsupported. **Writes** accept a `Double` ms /
+ `TimeSpan` (TIME, TOD) or a `DateTime` (DATE — truncated to a whole day — and
+ DT); negative, NaN, pre-1970 or > UDINT values are refused `BadOutOfRange`, and
+ a duration written to a date (or vice versa) `BadTypeMismatch`.
+ **Existing tags:** a tag authored with `dataType: "Time"` (etc.) now surfaces
+ the decoded type — re-browse/redeploy to pick up the new node type. A tag
+ authored `UDInt` against a TIME/DATE symbol is unchanged: it still reads and
+ writes the raw 32-bit counter (the guard tolerates the same-width divergence).
- **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2
target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if
the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS`
@@ -315,7 +332,7 @@ works:
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) |
| `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll |
| `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 |
-| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms |
+| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `Double[13]` `[0, 10, 20, 40, …]` ms (was `UInt32[13]` before #306; declare `UDInt` for the raw counter) |
| `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` |
| `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` |
| `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs
index 31c79615..a84ce5fb 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDataType.cs
@@ -20,10 +20,9 @@ public enum TwinCATDataType
String, // ASCII string
WString,// UTF-16 string
// IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT)
- // raw counters. The driver surfaces them as DriverDataType.UInt32, so
- // operators see the raw counter, not a decoded date/duration. Proper decoding to
- // DriverDataType.DateTime is a future enhancement; until then comments accurately describe
- // the on-wire encoding so the next implementer doesn't re-derive it wrong.
+ // raw counters. The driver decodes them (#306): TIME / TOD publish Double milliseconds
+ // (DriverDataType.Float64), DATE / DT publish a UTC DateTime (DriverDataType.DateTime). A tag that
+ // wants the raw counter declares UDInt against the same symbol.
Time, // TIME — duration in milliseconds, stored as UDINT (32-bit unsigned)
Date, // DATE — seconds since 1970-01-01 truncated to a day boundary, stored as UDINT
DateTime, // DT (DATE_AND_TIME) — seconds since 1970-01-01, stored as UDINT
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index bc10d2c4..396db079 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -155,7 +155,10 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
if (result.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)result.ErrorCode));
- return (result.Value, TwinCATStatusMapper.Good);
+ return (TwinCATTemporalCodec.IsTemporal(type) && result.Value is uint rawTemporal
+ ? TwinCATTemporalCodec.Decode(type, rawTemporal)
+ : result.Value,
+ TwinCATStatusMapper.Good);
}
catch (AdsErrorException ex)
{
@@ -412,6 +415,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
+ else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
+ value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
}
@@ -696,7 +701,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
_ => typeof(int),
};
- private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
+ internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
{
TwinCATDataType.Bool => Convert.ToBoolean(value),
TwinCATDataType.SInt => Convert.ToSByte(value),
@@ -711,7 +716,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date
- or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
+ or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
index 7d471bd1..a6248931 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
@@ -73,15 +73,28 @@ internal static class TwinCATArrayCodec
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]);
return a;
}
- case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date
- or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay:
+ case TwinCATDataType.UDInt:
{
- // TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a
- // scalar of those types publishes.
var a = new uint[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]);
return a;
}
+ case TwinCATDataType.Time or TwinCATDataType.TimeOfDay:
+ {
+ // TIME / TOD publish milliseconds as double — the same CLR shape a scalar publishes (#306).
+ var a = new double[n];
+ for (var i = 0; i < n; i++)
+ a[i] = (double)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]));
+ return a;
+ }
+ case TwinCATDataType.Date or TwinCATDataType.DateTime:
+ {
+ // DATE / DT publish a UTC DateTime, as a scalar does (#306).
+ var a = new DateTime[n];
+ for (var i = 0; i < n; i++)
+ a[i] = (DateTime)TwinCATTemporalCodec.Decode(declared, BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]));
+ return a;
+ }
case TwinCATDataType.LInt:
{
var a = new long[n];
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs
index bd070be0..114fd1f5 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataTypeExtensions.cs
@@ -22,8 +22,10 @@ public static class TwinCATDataTypeExtensions
TwinCATDataType.Real => DriverDataType.Float32,
TwinCATDataType.LReal => DriverDataType.Float64,
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
- TwinCATDataType.Time or TwinCATDataType.Date
- or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32,
+ // TIME / TOD → Double milliseconds, DATE / DT → UTC DateTime (#306); the driver decodes the UDINT
+ // wire counter in AdsTwinCATClient. A tag that wants the raw counter declares UDInt instead.
+ TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Float64,
+ TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
TwinCATDataType.Structure => DriverDataType.String,
_ => DriverDataType.Int32,
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs
new file mode 100644
index 00000000..6992ee52
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs
@@ -0,0 +1,72 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+///
+/// Converts the IEC 61131-3 temporal types between their 32-bit unsigned (UDINT) wire counters and the
+/// values the driver publishes (#637 / #306). TIME and TOD surface as a of
+/// milliseconds (OPC UA Double; no new DriverDataType member — see
+/// docs/drivers/TwinCAT.md); DATE and DT surface as a UTC .
+///
+internal static class TwinCATTemporalCodec
+{
+ private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /// True for TIME / DATE / DT / TOD.
+ public static bool IsTemporal(TwinCATDataType type) =>
+ type is TwinCATDataType.Time or TwinCATDataType.Date
+ or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay;
+
+ /// Decodes a raw UDINT counter into the published value for .
+ public static object Decode(TwinCATDataType type, uint raw) => type switch
+ {
+ TwinCATDataType.Time or TwinCATDataType.TimeOfDay => (double)raw,
+ TwinCATDataType.Date or TwinCATDataType.DateTime => Epoch.AddSeconds(raw),
+ _ => throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type"),
+ };
+
+ ///
+ /// Encodes a published/written value into the UDINT counter. Accepts the native shape
+ /// ( ms / / ) and any other
+ /// numeric as the raw counter, so a client writing an integer keeps working.
+ ///
+ /// Out of the UDINT range, negative, or NaN/infinite.
+ /// A value of no accepted shape.
+ public static uint Encode(TwinCATDataType type, object? value)
+ {
+ switch (type)
+ {
+ case TwinCATDataType.Time or TwinCATDataType.TimeOfDay:
+ return value switch
+ {
+ TimeSpan ts => ToUInt(ts.TotalMilliseconds),
+ DateTime => throw new InvalidCastException($"{type} takes a duration in milliseconds, not a DateTime"),
+ double d => ToUInt(d),
+ float f => ToUInt(f),
+ _ => Convert.ToUInt32(value),
+ };
+ case TwinCATDataType.Date or TwinCATDataType.DateTime:
+ if (value is DateTime dt)
+ {
+ var utc = dt.Kind == DateTimeKind.Local ? dt.ToUniversalTime() : dt;
+ var secs = ToUInt(Math.Floor((utc - Epoch).TotalSeconds));
+ // DATE is a whole-day counter.
+ return type == TwinCATDataType.Date ? secs - secs % 86400u : secs;
+ }
+ if (value is TimeSpan) throw new InvalidCastException($"{type} takes a DateTime, not a duration");
+ return value switch
+ {
+ double d => ToUInt(d),
+ float f => ToUInt(f),
+ _ => Convert.ToUInt32(value),
+ };
+ default:
+ throw new ArgumentOutOfRangeException(nameof(type), type, "not a temporal type");
+ }
+ }
+
+ private static uint ToUInt(double v)
+ {
+ if (double.IsNaN(v) || double.IsInfinity(v) || v < 0 || v > uint.MaxValue)
+ throw new OverflowException($"{v} is outside the 32-bit unsigned range of an IEC temporal value");
+ return (uint)Math.Round(v, MidpointRounding.AwayFromZero);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs
index d032d4a8..fa395b6c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayCodecTests.cs
@@ -194,18 +194,28 @@ public sealed class TwinCATArrayCodecTests
}
///
- /// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (uint[]) — the same
- /// CLR shape a scalar of those types publishes. Live: .THROTTLE_MODE_DEFAULT
- /// (ARRAY [0..12] OF TIME) begins 0, 10, 20 ms.
+ /// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same
+ /// CLR shape a scalar publishes. Live: .THROTTLE_MODE_DEFAULT (ARRAY [0..12] OF TIME)
+ /// begins 0, 10, 20 ms.
///
[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 });
+
+ /// DATE / DT elements decode to UTC DateTime from seconds since 1970.
+ [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),
+ });
/// A BOOL element is one byte; any non-zero byte is TRUE (live: .ARGTYPE_IS_BIT_TYPE).
[Fact]
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
index 7d6c5658..d512700a 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
@@ -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);
}
/// Verifies that status mapper covers known ADS error codes.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
index aa356530..63017489 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
@@ -21,17 +21,16 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
- /// Verifies that IEC time types map to uint32 raw counters.
+ /// IEC time types surface as native values (#306): TIME/TOD as Double ms, DATE/DT as DateTime.
[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 ----
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs
new file mode 100644
index 00000000..f9b80afd
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs
@@ -0,0 +1,77 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+/// #306 — TIME / DATE / DT / TOD decode and encode round trips on known values.
+[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(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, value));
+
+ [Fact]
+ public void Dt_before_1970_is_refused()
+ => Should.Throw(() =>
+ 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(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, Dt));
+ Should.Throw(() => 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);
+ }
+}