diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs
index f980d95..b784b0b 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverDataType.cs
@@ -25,4 +25,11 @@ public enum DriverDataType
/// Galaxy-style attribute reference encoded as an OPC UA String.
Reference,
+
+ ///
+ /// OPC UA Duration — a Double-encoded period in milliseconds. Subtype of Double
+ /// in the address space; surfaced as in the driver layer.
+ /// Used by IEC 61131-3 TIME / TOD attributes (TwinCAT et al.).
+ ///
+ Duration,
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index 5c684c4..2043857 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -58,6 +58,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var value = result.Value;
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
+ value = PostProcessIecTime(type, value);
return (value, TwinCATStatusMapper.Good);
}
@@ -143,6 +144,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
+ value = PostProcessIecTime(reg.Type, value);
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
}
@@ -249,7 +251,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),
@@ -263,11 +265,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.Real => Convert.ToSingle(value),
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),
+ // IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
+ // IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
+ TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
+ TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
};
+ // IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
+ private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ /// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
+ /// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
+ /// types pass through unchanged.
+ ///
+ internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
+ {
+ if (value is null) return null;
+ var raw = TryGetUInt32(value);
+ if (raw is null) return value;
+ return type switch
+ {
+ // TIME / TOD — UDINT milliseconds.
+ TwinCATDataType.Time or TwinCATDataType.TimeOfDay
+ => TimeSpan.FromMilliseconds(raw.Value),
+ // DT — UDINT seconds since 1970-01-01 UTC.
+ TwinCATDataType.DateTime
+ => IecEpochUtc.AddSeconds(raw.Value),
+ // DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
+ // component to midnight; pass through the same conversion so we get a date-only
+ // value at midnight UTC.
+ TwinCATDataType.Date
+ => IecEpochUtc.AddSeconds(raw.Value),
+ _ => value,
+ };
+ }
+
+ private static uint? TryGetUInt32(object value) => value switch
+ {
+ uint u => u,
+ int i when i >= 0 => (uint)i,
+ ushort us => (uint)us,
+ short s when s >= 0 => (uint)s,
+ long l when l >= 0 && l <= uint.MaxValue => (uint)l,
+ ulong ul when ul <= uint.MaxValue => (uint)ul,
+ _ => null,
+ };
+
+ private static uint DurationToUDInt(object? value) => value switch
+ {
+ TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
+ // OPC UA Duration on the wire is a Double in milliseconds.
+ double d => (uint)Math.Max(0, d),
+ float f => (uint)Math.Max(0, f),
+ _ => Convert.ToUInt32(value),
+ };
+
+ private static uint DateTimeToUDInt(object? value)
+ {
+ if (value is DateTime dt)
+ {
+ var utc = dt.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
+ : dt.ToUniversalTime();
+ var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
+ if (seconds < 0 || seconds > uint.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(value),
+ "DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
+ return (uint)seconds;
+ }
+ return Convert.ToUInt32(value);
+ }
+
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
{
short s => (s & (1 << bit)) != 0,
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
index d123451..3c25e25 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDataType.cs
@@ -42,8 +42,11 @@ 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.Int32,
+ // IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
+ // The wire form is UDINT but the driver post-processes into TimeSpan / DateTime so the
+ // address space surfaces native UA Duration / DateTime instead of opaque integers.
+ TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Duration,
+ TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
TwinCATDataType.Structure => DriverDataType.String,
_ => DriverDataType.Int32,
};
diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
index b99ad75..e1331a7 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs
@@ -310,6 +310,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
DriverDataType.Float64 => DataTypeIds.Double,
DriverDataType.String => DataTypeIds.String,
DriverDataType.DateTime => DataTypeIds.DateTime,
+ DriverDataType.Duration => DataTypeIds.Duration,
_ => DataTypeIds.BaseDataType,
};
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
index caa97d1..2bdc2a5 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDriverTests.cs
@@ -89,7 +89,91 @@ public sealed class TwinCATDriverTests
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
- TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Int32);
+ // IEC durations map to UA Duration; absolute timestamps map to UA DateTime.
+ TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Duration);
+ TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Duration);
+ TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
+ TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
+ }
+
+ [Fact]
+ public void IecTime_post_process_converts_TIME_to_TimeSpan_ms()
+ {
+ var ts = (TimeSpan)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.Time, 12_345u)!;
+ ts.ShouldBe(TimeSpan.FromMilliseconds(12_345));
+ }
+
+ [Fact]
+ public void IecTime_post_process_converts_TOD_to_TimeSpan_ms()
+ {
+ var ts = (TimeSpan)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.TimeOfDay, 3_600_000u)!;
+ ts.ShouldBe(TimeSpan.FromHours(1));
+ }
+
+ [Fact]
+ public void IecTime_post_process_converts_DT_to_DateTime_utc()
+ {
+ // 1970-01-01 + 1 hour = 1970-01-01 01:00:00 UTC
+ var dt = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DateTime, 3600u)!;
+ dt.ShouldBe(new DateTime(1970, 1, 1, 1, 0, 0, DateTimeKind.Utc));
+ dt.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ [Fact]
+ public void IecTime_post_process_converts_DATE_to_midnight_utc()
+ {
+ // 86400 seconds = exactly 1970-01-02 00:00 UTC
+ var dt = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.Date, 86_400u)!;
+ dt.ShouldBe(new DateTime(1970, 1, 2, 0, 0, 0, DateTimeKind.Utc));
+ }
+
+ [Fact]
+ public void IecTime_post_process_passthrough_for_non_time_types()
+ {
+ AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DInt, 42).ShouldBe(42);
+ AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.LReal, 3.14).ShouldBe(3.14);
+ }
+
+ [Fact]
+ public void ConvertForWrite_TIME_accepts_TimeSpan_and_returns_UDINT_ms()
+ {
+ var raw = AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Time, TimeSpan.FromMilliseconds(2_500));
+ raw.ShouldBeOfType();
+ ((uint)raw).ShouldBe(2_500u);
+ }
+
+ [Fact]
+ public void ConvertForWrite_TOD_accepts_double_ms()
+ {
+ var raw = AdsTwinCATClient.ConvertForWrite(TwinCATDataType.TimeOfDay, 60_000.0);
+ ((uint)raw).ShouldBe(60_000u);
+ }
+
+ [Fact]
+ public void ConvertForWrite_DT_accepts_DateTime_and_returns_UDINT_seconds()
+ {
+ var raw = AdsTwinCATClient.ConvertForWrite(
+ TwinCATDataType.DateTime,
+ new DateTime(1970, 1, 1, 1, 0, 0, DateTimeKind.Utc));
+ ((uint)raw).ShouldBe(3600u);
+ }
+
+ [Fact]
+ public void ConvertForWrite_DT_round_trips_via_post_process()
+ {
+ var original = new DateTime(2024, 6, 15, 12, 30, 45, DateTimeKind.Utc);
+ var raw = (uint)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.DateTime, original);
+ var roundTrip = (DateTime)AdsTwinCATClient.PostProcessIecTime(TwinCATDataType.DateTime, raw)!;
+ roundTrip.ShouldBe(original);
+ }
+
+ [Fact]
+ public void ConvertForWrite_DT_rejects_pre_epoch_values()
+ {
+ Should.Throw(() =>
+ AdsTwinCATClient.ConvertForWrite(
+ TwinCATDataType.DateTime,
+ new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc)));
}
[Theory]