From 80b2d7f8c323f68ba5d21e20774abebf5d25553f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 17:14:12 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-1.2=20=E2=80=94=20native=20UA?= =?UTF-8?q?=20TIME/DATE/DT/TOD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IEC 61131-3 TIME/TOD now surface as TimeSpan (UA Duration); DATE/DT surface as DateTime (UTC). The wire form stays UDINT — AdsTwinCATClient post-processes raw values in ReadValueAsync and OnAdsNotificationEx, and accepts native CLR types in ConvertForWrite. Added Duration to DriverDataType (back-compat: existing switches default to BaseDataType for unknown enum values) and mapped it to DataTypeIds.Duration in DriverNodeManager. Closes #306 --- .../DriverDataType.cs | 7 ++ .../AdsTwinCATClient.cs | 76 +++++++++++++++- .../TwinCATDataType.cs | 7 +- .../OpcUa/DriverNodeManager.cs | 1 + .../TwinCATDriverTests.cs | 86 ++++++++++++++++++- 5 files changed, 171 insertions(+), 6 deletions(-) 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] -- 2.49.1