From 90e3fe1cf758d30ccee283626bbd731d938407d9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 14:24:12 -0400 Subject: [PATCH 1/2] 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 --- docs/drivers/TwinCAT.md | 19 ++++- .../TwinCATDataType.cs | 7 +- .../AdsTwinCATClient.cs | 11 ++- .../TwinCATArrayCodec.cs | 21 ++++- .../TwinCATDataTypeExtensions.cs | 6 +- .../TwinCATTemporalCodec.cs | 72 +++++++++++++++++ .../TwinCATArrayCodecTests.cs | 24 ++++-- .../TwinCATDriverTests.cs | 4 +- .../TwinCATLowFindingsRegressionTests.cs | 17 ++-- .../TwinCATTemporalCodecTests.cs | 77 +++++++++++++++++++ 10 files changed, 226 insertions(+), 32 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs 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); + } +} From 05ed039c73ce780744414fd7132a3d0e7750547d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 14:26:32 -0400 Subject: [PATCH 2/2] fix(twincat): TOD write bound, historian note, driver-level refused-write test (#306) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/TwinCAT.md | 6 ++++ .../TwinCATTemporalCodec.cs | 6 +++- .../TwinCATReadWriteTests.cs | 29 +++++++++++++++++++ .../TwinCATTemporalCodecTests.cs | 6 ++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md index 2d1fab00..a878f170 100644 --- a/docs/drivers/TwinCAT.md +++ b/docs/drivers/TwinCAT.md @@ -152,6 +152,12 @@ found against a real TwinCAT 2 PLC: 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). + **Historian:** a historized tag authored `Date`/`DateTime` now publishes + `DriverDataType.DateTime`, which continuous historization skips as non-numeric + (it was historized as `UInt32` before) — such tags stop recording. Author them + `UDInt` to keep historizing the raw counter. `Time`/`TimeOfDay` are now + `Float64` and still historize. A `TimeOfDay` write of 86 400 000 ms or more + is refused `BadOutOfRange`. - **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` diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs index 6992ee52..065c515d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTemporalCodec.cs @@ -35,7 +35,7 @@ internal static class TwinCATTemporalCodec switch (type) { case TwinCATDataType.Time or TwinCATDataType.TimeOfDay: - return value switch + var ms = value switch { TimeSpan ts => ToUInt(ts.TotalMilliseconds), DateTime => throw new InvalidCastException($"{type} takes a duration in milliseconds, not a DateTime"), @@ -43,6 +43,10 @@ internal static class TwinCATTemporalCodec float f => ToUInt(f), _ => Convert.ToUInt32(value), }; + // TOD is a time of day: a full day or more is not representable. + if (type == TwinCATDataType.TimeOfDay && ms >= 86_400_000u) + throw new OverflowException($"{ms} ms is not a time of day (< 86 400 000)"); + return ms; case TwinCATDataType.Date or TwinCATDataType.DateTime: if (value is DateTime dt) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs index 6d80d837..c81e7b88 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs @@ -191,6 +191,35 @@ public sealed class TwinCATReadWriteTests results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); } + /// A refused temporal write (#306) surfaces a status through WriteAsync, never an exception. + [Theory] + [InlineData(TwinCATDataType.TimeOfDay, 86_400_000d, TwinCATStatusMapper.BadOutOfRange)] + [InlineData(TwinCATDataType.Time, -1d, TwinCATStatusMapper.BadOutOfRange)] + [InlineData(TwinCATDataType.DateTime, "TimeSpan", TwinCATStatusMapper.BadTypeMismatch)] + public async Task Refused_temporal_write_surfaces_status_not_exception( + TwinCATDataType type, object value, uint expected) + { + var (drv, factory) = NewDriver(new TwinCATTagDefinition("T", "ads://5.23.91.23.1.1:851", "MAIN.T", type)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = () => new ConvertingFake(); + if (value is "TimeSpan") value = TimeSpan.FromSeconds(1); + + var results = await drv.WriteAsync([new WriteRequest("T", value)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(expected); + } + + // Runs the production write conversion the fake otherwise skips. + private sealed class ConvertingFake : FakeTwinCATClient + { + public override Task WriteValueAsync( + string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct) + { + AdsTwinCATClient.ConvertForWrite(type, value); + return base.WriteValueAsync(symbolPath, type, bitIndex, value, ct); + } + } + /// Verifies that write exceptions surface as BadCommunicationError status. [Fact] public async Task Write_exception_surfaces_BadCommunicationError() 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 index f9b80afd..9b5c41e7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTemporalCodecTests.cs @@ -53,6 +53,12 @@ public sealed class TwinCATTemporalCodecTests public void Time_encode_refuses_out_of_range(double value) => Should.Throw(() => TwinCATTemporalCodec.Encode(TwinCATDataType.Time, value)); + [Theory] + [InlineData(86_400_000d)] + [InlineData(-1d)] + public void Tod_encode_refuses_a_full_day_or_more(double value) + => Should.Throw(() => TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, value)); + [Fact] public void Dt_before_1970_is_refused() => Should.Throw(() =>