[twincat] TwinCAT — Native UA TIME/DATE/DT/TOD #342

Merged
dohertj2 merged 1 commits from auto/twincat/1.2 into auto/driver-gaps 2026-04-25 17:16:41 -04:00
5 changed files with 171 additions and 6 deletions

View File

@@ -25,4 +25,11 @@ public enum DriverDataType
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
Reference,
/// <summary>
/// OPC UA <c>Duration</c> — a Double-encoded period in milliseconds. Subtype of Double
/// in the address space; surfaced as <see cref="System.TimeSpan"/> in the driver layer.
/// Used by IEC 61131-3 <c>TIME</c> / <c>TOD</c> attributes (TwinCAT et al.).
/// </summary>
Duration,
}

View File

@@ -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);
/// <summary>
/// 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.
/// </summary>
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,

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>();
((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<ArgumentOutOfRangeException>(() =>
AdsTwinCATClient.ConvertForWrite(
TwinCATDataType.DateTime,
new DateTime(1969, 12, 31, 23, 59, 59, DateTimeKind.Utc)));
}
[Theory]