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
This commit is contained in:
Joseph Doherty
2026-10-08 14:24:12 -04:00
parent d898262c4a
commit 90e3fe1cf7
10 changed files with 226 additions and 32 deletions
@@ -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
@@ -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."),
};
@@ -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];
@@ -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,
};
@@ -0,0 +1,72 @@
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:
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);
}
}