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(() =>