[twincat] TwinCAT — Native UA TIME/DATE/DT/TOD #342
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user