Merge b626-twincat (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:07 -04:00
11 changed files with 271 additions and 32 deletions
+24 -1
View File
@@ -135,6 +135,29 @@ found against a real TwinCAT 2 PLC:
registers, and the probe-tick retry re-checks it after a PLC program change. 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 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. 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).
**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 - **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 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` the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS`
@@ -315,7 +338,7 @@ works:
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) | | `.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 | | `.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 | | `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]` | | `.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]` | | `.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) | | `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
@@ -20,10 +20,9 @@ public enum TwinCATDataType
String, // ASCII string String, // ASCII string
WString,// UTF-16 string WString,// UTF-16 string
// IEC 61131-3 / TwinCAT temporal types — all stored on the wire as 32-bit unsigned (UDINT) // 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 // raw counters. The driver decodes them (#306): TIME / TOD publish Double milliseconds
// operators see the raw counter, not a decoded date/duration. Proper decoding to // (DriverDataType.Float64), DATE / DT publish a UTC DateTime (DriverDataType.DateTime). A tag that
// DriverDataType.DateTime is a future enhancement; until then comments accurately describe // wants the raw counter declares UDInt against the same symbol.
// the on-wire encoding so the next implementer doesn't re-derive it wrong.
Time, // TIME — duration in milliseconds, stored as UDINT (32-bit unsigned) 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 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 DateTime, // DT (DATE_AND_TIME) — seconds since 1970-01-01, stored as UDINT
@@ -155,7 +155,10 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
if (result.ErrorCode != AdsErrorCode.NoError) if (result.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)result.ErrorCode)); 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) 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) else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8); 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)); _notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
} }
@@ -696,7 +701,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
_ => typeof(int), _ => 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.Bool => Convert.ToBoolean(value),
TwinCATDataType.SInt => Convert.ToSByte(value), TwinCATDataType.SInt => Convert.ToSByte(value),
@@ -711,7 +716,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.LReal => Convert.ToDouble(value), TwinCATDataType.LReal => Convert.ToDouble(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty, TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date 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."), _ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
}; };
@@ -73,15 +73,28 @@ internal static class TwinCATArrayCodec
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]); for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]);
return a; return a;
} }
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date case TwinCATDataType.UDInt:
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay:
{ {
// 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]; var a = new uint[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]); for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]);
return a; 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: case TwinCATDataType.LInt:
{ {
var a = new long[n]; var a = new long[n];
@@ -22,8 +22,10 @@ public static class TwinCATDataTypeExtensions
TwinCATDataType.Real => DriverDataType.Float32, TwinCATDataType.Real => DriverDataType.Float32,
TwinCATDataType.LReal => DriverDataType.Float64, TwinCATDataType.LReal => DriverDataType.Float64,
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String, TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
TwinCATDataType.Time or TwinCATDataType.Date // TIME / TOD → Double milliseconds, DATE / DT → UTC DateTime (#306); the driver decodes the UDINT
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.UInt32, // 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, TwinCATDataType.Structure => DriverDataType.String,
_ => DriverDataType.Int32, _ => DriverDataType.Int32,
}; };
@@ -0,0 +1,76 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// 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 <see cref="double"/> of
/// milliseconds (OPC UA <c>Double</c>; no new <c>DriverDataType</c> member — see
/// <c>docs/drivers/TwinCAT.md</c>); DATE and DT surface as a UTC <see cref="DateTime"/>.
/// </summary>
internal static class TwinCATTemporalCodec
{
private static readonly DateTime Epoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>True for TIME / DATE / DT / TOD.</summary>
public static bool IsTemporal(TwinCATDataType type) =>
type is TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay;
/// <summary>Decodes a raw UDINT counter into the published value for <paramref name="type"/>.</summary>
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"),
};
/// <summary>
/// Encodes a published/written value into the UDINT counter. Accepts the native shape
/// (<see cref="double"/> ms / <see cref="DateTime"/> / <see cref="TimeSpan"/>) and any other
/// numeric as the raw counter, so a client writing an integer keeps working.
/// </summary>
/// <exception cref="OverflowException">Out of the UDINT range, negative, or NaN/infinite.</exception>
/// <exception cref="InvalidCastException">A value of no accepted shape.</exception>
public static uint Encode(TwinCATDataType type, object? value)
{
switch (type)
{
case TwinCATDataType.Time or TwinCATDataType.TimeOfDay:
var ms = 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),
};
// 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)
{
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);
}
}
@@ -194,18 +194,28 @@ public sealed class TwinCATArrayCodecTests
} }
/// <summary> /// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same /// TIME / TOD elements publish as Double milliseconds and DATE / DT as UTC DateTime (#306) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c> /// CLR shape a scalar publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c> (<c>ARRAY [0..12] OF TIME</c>)
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms. /// begins <c>0, 10, 20</c> ms.
/// </summary> /// </summary>
[Theory] [Theory]
[InlineData(TwinCATDataType.Time)] [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.Date)]
[InlineData(TwinCATDataType.DateTime)] [InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)] public void Decode_date_elements_as_utc_datetime(TwinCATDataType type)
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type) => TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 60, 0, 0, 0], new TwinCATArrayLayout(2, 4), utf8: false)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false) .ShouldBe(new[]
.ShouldBe(new uint[] { 0, 10, 20 }); {
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> /// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[Fact] [Fact]
@@ -93,8 +93,8 @@ public sealed class TwinCATDriverTests
TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64); TwinCATDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String); TwinCATDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String); TwinCATDataType.WString.ToDriverDataType().ShouldBe(DriverDataType.String);
// IEC TIME/DATE/DT/TOD surface as their raw UDINT counter (Driver.TwinCAT-002). // IEC TIME/TOD surface as Double ms, DATE/DT as DateTime (#306).
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
} }
/// <summary>Verifies that status mapper covers known ADS error codes.</summary> /// <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 ---- // ---- 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] [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 TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.Float64);
// surface as their raw UDINT counter (32-bit unsigned), not as decoded DateTime/TimeSpan. TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.Float64);
// The next implementer who wants to decode them needs to see this mapping is intentional. TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.DateTime);
TwinCATDataType.Date.ToDriverDataType().ShouldBe(DriverDataType.UInt32); // The raw counter stays reachable by declaring UDInt.
TwinCATDataType.DateTime.ToDriverDataType().ShouldBe(DriverDataType.UInt32); TwinCATDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
TwinCATDataType.TimeOfDay.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
} }
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ---- // ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
@@ -191,6 +191,35 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable); results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
} }
/// <summary>A refused temporal write (#306) surfaces a status through WriteAsync, never an exception.</summary>
[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<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
AdsTwinCATClient.ConvertForWrite(type, value);
return base.WriteValueAsync(symbolPath, type, bitIndex, value, ct);
}
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary> /// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary>
[Fact] [Fact]
public async Task Write_exception_surfaces_BadCommunicationError() public async Task Write_exception_surfaces_BadCommunicationError()
@@ -0,0 +1,83 @@
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));
[Theory]
[InlineData(86_400_000d)]
[InlineData(-1d)]
public void Tod_encode_refuses_a_full_day_or_more(double value)
=> Should.Throw<OverflowException>(() => TwinCATTemporalCodec.Encode(TwinCATDataType.TimeOfDay, 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);
}
}