Merge b626-twincat (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+24
-1
@@ -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>
|
||||||
|
|||||||
+8
-9
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user