Merge TwinCAT fixes ported from ignitionoee + live-validated on the Elma PLCs
This commit is contained in:
@@ -124,7 +124,7 @@ UDTs / function-block instances don't appear.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`) |
|
||||
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`). A TwinCAT 2 global keeps its leading dot, so filter with it (`.OPC_`) |
|
||||
| `--max` | `500` | Max symbols to print. `0` = unbounded |
|
||||
|
||||
```powershell
|
||||
|
||||
+91
-3
@@ -51,7 +51,13 @@ an AMS Net ID + port.
|
||||
is six dot-separated octets (NOT an IP — a Beckhoff-specific identifier the
|
||||
router maps to a route); the port is the AMS service port (851 = TC3 PLC runtime
|
||||
1, 852 = runtime 2, 801 / 811 / 821 = TC2 PLC runtimes). Port defaults to 851
|
||||
when omitted (`ads://5.23.91.23.1.1`).
|
||||
when omitted (`ads://5.23.91.23.1.1`) — **a TwinCAT 2 target must name `:801`**.
|
||||
|
||||
The driver never registers a route on the PLC: it sends no UDP `AddRoute` and
|
||||
writes nothing to the PLC's route table. The route from the PLC back to this
|
||||
host's AMS Net ID (and the AMS router's route to the PLC) is provisioned
|
||||
out-of-band — the same posture as the ignitionoee driver's
|
||||
`Register Route on Connect = off` (TwinCAT-009).
|
||||
|
||||
### Symbol path form
|
||||
|
||||
@@ -62,6 +68,15 @@ mirrors IEC 61131-3 structured-text identifiers: global-variable-list
|
||||
(`Motor1.Status.Running`), array subscripts (`Data[5]`, `Matrix[1,2]`), and
|
||||
bit-access (`Flags.0`).
|
||||
|
||||
**TwinCAT 2 globals keep their leading dot.** A TC2 global is named with ONE
|
||||
leading dot (`.OPC_HEARTBEAT`, `.OPC_MACHINEDATA.PROCESSSTATUS`,
|
||||
`.ARRCARRIERDATA_V[1].SWORKORDER`) and the PLC requires it — without it
|
||||
`SYM_HNDBYNAME` answers 0x710 (symbol not found), observed live on a TC 2.11 CX.
|
||||
The parser keeps the dot on the root segment, so bit access on a TC2 global
|
||||
(`.OPC_STATUS.WORD.3`) still reads the parent word `.OPC_STATUS.WORD`. Exactly one
|
||||
dot: `..x`, `.` and `.[1]` are rejected. (Before this, the dot made the parser
|
||||
give up and the raw string went to ADS verbatim, losing bit-index handling.)
|
||||
|
||||
**BOOL-within-word writes** — writing a bit-access path (e.g. `MAIN.Flags.3`)
|
||||
is a driver-level parent-word read-modify-write: the driver reads the parent
|
||||
word as `UDInt`, flips the target bit, and writes the word back, under a
|
||||
@@ -79,6 +94,74 @@ globals / program locals under a `Discovered/` sub-folder; any symbol-loader
|
||||
error falls back to pre-declared-only so a flaky symbol download never blocks
|
||||
discovery.
|
||||
|
||||
Discovered variables are named by their full instance path (flat, no dot
|
||||
splitting), so a TC2 global appears as `.OPC_HEARTBEAT` — with its dot, never
|
||||
as an empty-named folder. TwinCAT 2 reports many top-level integers by their
|
||||
ADS base-type name (`INT16`, `UINT16` — 354 symbols on a real TC 2.11 Elma
|
||||
cleaner) and pads some member type names (`"REAL "`); both map like their IEC
|
||||
twins, so those symbols are no longer silently dropped. The CLI `browse
|
||||
--prefix` filter is a case-sensitive `StartsWith` on that name, so filter TC2
|
||||
globals with the dot (`--prefix .OPC_`; `OPC_` matches nothing).
|
||||
|
||||
## Strings and declared-type checking
|
||||
|
||||
Ported from the ignitionoee TwinCAT driver (0.19.2–0.19.4), where each was
|
||||
found against a real TwinCAT 2 PLC:
|
||||
|
||||
- **A `String` / `WString` tag matches a PLC `STRING(n)` / `WSTRING(n)` of any
|
||||
`n`** and is read at the PLC's own width — `STRING(n)` is n+1 bytes,
|
||||
`WSTRING(n)` 2(n+1). This also applies to native notifications, which
|
||||
previously could not register a string at all (the Beckhoff marshaller needs
|
||||
an explicit length and threw), taking the rest of that subscribe call down
|
||||
with it.
|
||||
- **A string ends at its first NUL** (`0x00` for STRING, an aligned `U+0000`
|
||||
for WSTRING). Bytes after it are stale content from a longer earlier value
|
||||
(live: work order `67504440` read back with a stale `"\0" + "1"` tail).
|
||||
- **A STRING decodes as Windows-1252** (umlauts, `°` in alarm texts), or UTF-8
|
||||
when the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding
|
||||
for STRING"). It no longer depends on the server's culture — the Beckhoff
|
||||
marshaller falls back to the host's ANSI code page, which is 1250 on a Polish
|
||||
host. Writes still go through the Beckhoff marshaller.
|
||||
- **String-ness is a kind.** A numeric tag over a PLC string, a string tag over
|
||||
a numeric, `String` over a `WSTRING` (or vice versa), and a string over a
|
||||
struct/array are refused with `BadTypeMismatch` and a WARN, instead of
|
||||
publishing the PLC's bytes as the wrong type at Good.
|
||||
- **Native notifications are additionally width- and float-checked.** A
|
||||
notification delivers raw bytes decoded as the *declared* type, so `DInt`
|
||||
over a PLC `REAL` (same width, bit pattern reinterpreted) or `DInt` over
|
||||
`LINT` (truncated) is refused too. A same-width, same-kind divergence (`DInt`
|
||||
vs `UDINT`, `Time` vs a 4-byte integer) is tolerated. A refused tag is
|
||||
isolated: it publishes `BadTypeMismatch`, the rest of the subscription
|
||||
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
|
||||
the PLC's own type, so only the string-kind rule applies there.
|
||||
- **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`
|
||||
`STRING(80)` → 11 B, an `INT` member → 1, a `UDINT` → 1) while its resolved
|
||||
`DataType.ByteSize` is correct (81 / 2 / 4, matching raw `SYM_INFOBYNAMEEX`).
|
||||
String reads therefore address the symbol's index group/offset explicitly at
|
||||
the type's width — sizing them by the symbol truncated `"ready for operation"`
|
||||
to `"ready for o"` at Good.
|
||||
- **A `VAR_IN_OUT` member is a reference, not a value.** TC2 names it
|
||||
`STRING(20) (VAR_IN_OUT)` (4 bytes) but the library resolves its type to the
|
||||
plain `STRING(20)`; the symbol's own name wins and such a tag is refused
|
||||
(`BadTypeMismatch`) instead of publishing the memory at the pointer as text.
|
||||
|
||||
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
|
||||
|
||||
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733
|
||||
(`5.25.53.46.1.1:801`) over the VPN, through an in-process
|
||||
`Beckhoff.TwinCAT.Ads.TcpRouter` in a scratch harness: ReadState RUN; browse
|
||||
discovered 45,083 / 42,666 variables incl. the `INT16`/`UINT16` globals
|
||||
(`.ACTUALOPERATIONMODE`, `.ADSSTATE_RUN`) and 3,663 / 3,564 leading-dot names, no
|
||||
empty names; `.ADSSTATE_RUN` = 5 with bits `.0`/`.1`/`.2` = true/false/true;
|
||||
work-order and part-number strings whose PLC buffers carry a stale tail after
|
||||
the NUL (`68261005\0` + `01`) read as `68261005` through both poll and native
|
||||
notifications; `DInt` over a `STRING(80)` → `BadTypeMismatch` in both paths with
|
||||
the rest of the subscription Good. No PLC value held a genuine non-ASCII string
|
||||
to exercise Windows-1252 live — that rule is unit-tested only.
|
||||
|
||||
## Capability surface
|
||||
|
||||
`TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable`
|
||||
@@ -145,8 +228,13 @@ A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carr
|
||||
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
|
||||
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
|
||||
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
|
||||
response. When `UseNativeNotifications` is enabled, ADS also pushes array-value changes in
|
||||
a single notification payload — no per-element polling.
|
||||
response.
|
||||
|
||||
⚠️ **Native notifications do not carry arrays.** The notification path registers the
|
||||
declared *scalar* type, so before the declared-type check an array tag subscribed natively
|
||||
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
|
||||
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
|
||||
(the poll engine uses the array read above) until native array notifications are added.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
|
||||
the array symbol read path via the fake ADS client factory. The TwinCAT integration
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
|
||||
/// <summary>Gets or sets the TwinCAT symbol path to read.</summary>
|
||||
[CommandOption("symbol", 's', Description =
|
||||
"Symbol path. Program scope: 'MAIN.bStart'. Global: 'GVL.Counter'. " +
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'.",
|
||||
"Nested UDT member: 'Motor1.Status.Running'. Array element: 'Recipe[3]'. " +
|
||||
"TwinCAT 2 global (keep its leading dot): '.OPC_HEARTBEAT'.",
|
||||
IsRequired = true)]
|
||||
public string SymbolPath { get; init; } = default!;
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
private readonly CancellationTokenSource _dispatchCts = new();
|
||||
private readonly Task _dispatchLoop;
|
||||
private int _disposed;
|
||||
private bool? _stringsAreUtf8;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
|
||||
@@ -125,6 +126,30 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return (arrResult.Value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
// Scalar read: check the declared type against the PLC symbol first. The symbol is
|
||||
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
|
||||
// internally), so this costs no extra round trip after the first read.
|
||||
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
|
||||
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
|
||||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
|
||||
// A STRING / WSTRING is read as its raw bytes at the PLC's own declared width and
|
||||
// decoded here: it ends at the first NUL, and a STRING is Windows-1252 rather than the
|
||||
// host culture's ANSI code page the Beckhoff marshaller would fall back to. The width is
|
||||
// the RESOLVED type's (shape.ByteSize) and the read is addressed explicitly: asking the
|
||||
// library for typeof(byte[]) slices by the symbol's own ByteSize, which a TwinCAT 2
|
||||
// member symbol under-reports (STRING(80) → 11 B, live on a TC 2.11 CX).
|
||||
if (type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
{
|
||||
var (bytes, readStatus) = await ReadRawAsync(symbolPath, symbol!, shape.ByteSize, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return readStatus == TwinCATStatusMapper.Good
|
||||
? (DecodeString(type, bytes, StringsAreUtf8()), TwinCATStatusMapper.Good)
|
||||
: (null, readStatus);
|
||||
}
|
||||
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -140,6 +165,87 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
|
||||
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
|
||||
/// cache (lookup on), so repeat calls are local.
|
||||
/// </summary>
|
||||
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
|
||||
string symbolPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (default, null, MapAndSignal((uint)result.ErrorCode));
|
||||
if (result.Value is not { } symbol)
|
||||
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
|
||||
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
|
||||
var dataType = symbol.DataType?.Resolve();
|
||||
var shape = TwinCATPlcSymbolShape.Classify(
|
||||
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
|
||||
return (shape, symbol, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads exactly <paramref name="length"/> raw bytes of a resolved symbol: by its
|
||||
/// index group/offset, or — for a handle-addressed symbol (0xF005, whose offset is a handle
|
||||
/// that may be stale) — through a fresh variable handle.
|
||||
/// </summary>
|
||||
private async Task<(byte[] bytes, uint status)> ReadRawAsync(
|
||||
string symbolPath, IAdsSymbol symbol, int length, CancellationToken cancellationToken)
|
||||
{
|
||||
const uint SymbolValueByHandle = 0xF005;
|
||||
if (symbol.IndexGroup != SymbolValueByHandle)
|
||||
{
|
||||
var direct = await _client.ReadAsync(symbol.IndexGroup, symbol.IndexOffset, length, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return direct.ErrorCode == AdsErrorCode.NoError
|
||||
? (direct.Data.ToArray(), TwinCATStatusMapper.Good)
|
||||
: ([], MapAndSignal((uint)direct.ErrorCode));
|
||||
}
|
||||
|
||||
var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||
if (handle.ErrorCode != AdsErrorCode.NoError) return ([], MapAndSignal((uint)handle.ErrorCode));
|
||||
try
|
||||
{
|
||||
var read = await _client.ReadAsync(handle.Handle, length, cancellationToken).ConfigureAwait(false);
|
||||
return read.ErrorCode == AdsErrorCode.NoError
|
||||
? (read.Data.ToArray(), TwinCATStatusMapper.Good)
|
||||
: ([], MapAndSignal((uint)read.ErrorCode));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch { /* best-effort release */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the target reports UTF-8 STRING data (TwinCAT 3.1 4026+ "UTF8 Encoding for
|
||||
/// STRING"); otherwise a STRING is Windows-1252. Read once per client from the symbol upload
|
||||
/// info the Beckhoff client already fetches.
|
||||
/// </summary>
|
||||
private bool StringsAreUtf8()
|
||||
{
|
||||
if (_stringsAreUtf8 is bool known) return known;
|
||||
try
|
||||
{
|
||||
var utf8 = _client.DefaultValueEncoding.CodePage == System.Text.Encoding.UTF8.CodePage;
|
||||
_stringsAreUtf8 = utf8;
|
||||
return utf8;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // not determinable yet (e.g. not connected) — Windows-1252, retry next time
|
||||
}
|
||||
}
|
||||
|
||||
private static string DecodeString(TwinCATDataType type, byte[] bytes, bool utf8) =>
|
||||
type == TwinCATDataType.WString
|
||||
? TwinCATStringCodec.DecodeWString(bytes)
|
||||
: TwinCATStringCodec.DecodeString(bytes, utf8);
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
@@ -197,6 +303,33 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
int[]? anyTypeArgs = null;
|
||||
var stringsUtf8 = false;
|
||||
if (bitIndex is null || type != TwinCATDataType.Bool)
|
||||
{
|
||||
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
|
||||
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
|
||||
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
|
||||
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
|
||||
if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, reason);
|
||||
|
||||
// A string has no fixed width: the "any" type string needs an explicit length (the
|
||||
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
|
||||
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
|
||||
// and decode them in OnAdsNotificationEx with the same rules as a read.
|
||||
if (type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
{
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [shape.ByteSize];
|
||||
stringsUtf8 = StringsAreUtf8();
|
||||
}
|
||||
}
|
||||
|
||||
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, Math.Max(0, maxDelayMs));
|
||||
|
||||
@@ -204,13 +337,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
// key into _notifications.
|
||||
var result = await _client.AddDeviceNotificationExAsync(
|
||||
symbolPath, settings, userData: null, clrType, args: null, cancellationToken)
|
||||
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
throw new InvalidOperationException(
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle);
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||||
{
|
||||
StringsUtf8 = stringsUtf8,
|
||||
};
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
}
|
||||
@@ -227,6 +363,8 @@ 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);
|
||||
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
|
||||
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
|
||||
}
|
||||
|
||||
@@ -376,27 +514,36 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return (MapSymbolTypeName(dataType.Name), null);
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
|
||||
internal static TwinCATDataType? MapSymbolTypeName(string? typeName)
|
||||
{
|
||||
if (typeName is null) return null;
|
||||
// A TwinCAT 2 data-type upload pads some member type names with a trailing blank
|
||||
// ("REAL ", "DINT " on a TC 2.11 CX) — trim before matching.
|
||||
var name = typeName.Trim();
|
||||
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
|
||||
// the type name — strip the "(...)" suffix so sized strings map onto the bare
|
||||
// String/WString atom the driver speaks.
|
||||
var paren = typeName.IndexOf('(');
|
||||
var bare = paren > 0 ? typeName[..paren] : typeName;
|
||||
// String/WString atom the driver speaks. Only for the string kinds: any other
|
||||
// parenthesised form ("INT (VAR_IN_OUT)" — a 4-byte reference on TC2) is not the
|
||||
// scalar its prefix names and stays unsupported.
|
||||
var paren = name.IndexOf('(');
|
||||
var bare = paren > 0 ? name[..paren] : name;
|
||||
if (paren > 0 && bare is not ("STRING" or "WSTRING")) return null;
|
||||
return bare switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
// TwinCAT 2 reports top-level globals by their ADS base-type name (INT16 / UINT16 / …),
|
||||
// and the Beckhoff type system keeps that spelling as a built-in primitive
|
||||
// (AdsSymbolProvider.CreateBuildInTypes) — map each like its IEC twin.
|
||||
"USINT" or "UINT8" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" or "INT16" => TwinCATDataType.Int,
|
||||
"UINT" or "UINT16" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" or "INT32" => TwinCATDataType.DInt,
|
||||
"UDINT" or "UINT32" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"REAL" or "FLOAT" => TwinCATDataType.Real,
|
||||
"LREAL" or "DOUBLE" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
@@ -463,6 +610,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// </summary>
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
|
||||
/// </summary>
|
||||
public bool StringsUtf8 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the notification subscription.
|
||||
/// </summary>
|
||||
|
||||
@@ -94,6 +94,9 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
/// <returns>A handle whose disposal tears the registered notification down.</returns>
|
||||
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
|
||||
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
|
||||
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
|
||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
|
||||
@@ -276,9 +276,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
|
||||
_driverInstanceId, status, reference);
|
||||
if (status == TwinCATStatusMapper.BadTypeMismatch)
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
|
||||
_driverInstanceId, reference, def.DataType, symbolName);
|
||||
else
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
|
||||
_driverInstanceId, status, reference);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
}
|
||||
@@ -535,7 +541,22 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow))));
|
||||
|
||||
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException mismatch)
|
||||
{
|
||||
// The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008):
|
||||
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the
|
||||
// rest of the subscription. Its intent stays on the device with no live handle,
|
||||
// so the probe-tick retry re-checks it after a PLC program change.
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
|
||||
_driverInstanceId, reference, mismatch.Message);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow)));
|
||||
}
|
||||
registrations.Add(reg);
|
||||
}
|
||||
}
|
||||
@@ -569,9 +590,20 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride: null).ConfigureAwait(false);
|
||||
var newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
ITwinCATNotificationHandle newHandle;
|
||||
try
|
||||
{
|
||||
newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException)
|
||||
{
|
||||
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
|
||||
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
|
||||
device.NativeRegistrations[reg.Id] = reg;
|
||||
throw;
|
||||
}
|
||||
reg.SwapHandle(newHandle); // initial set — previous handle is null
|
||||
device.NativeRegistrations[reg.Id] = reg;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer read at the PLC's own declared width.
|
||||
/// The driver reads string symbols as raw bytes and decodes them here instead of letting the
|
||||
/// Beckhoff marshaller pick an encoding, because that marshaller falls back to the HOST's ANSI code
|
||||
/// page (<c>CultureInfo.CurrentCulture.TextInfo.ANSICodePage</c>) for a non-UTF-8 target — so a
|
||||
/// TwinCAT 2 string would decode differently on a Polish (1250) or Cyrillic (1251) server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two rules, both ported from the ignitionoee TwinCAT driver and both found on a real PLC:
|
||||
/// <list type="bullet">
|
||||
/// <item>A value ENDS at its first NUL — <c>0x00</c> for a STRING, an aligned <c>0x00 0x00</c>
|
||||
/// code unit for a WSTRING. Whatever follows is stale content a longer earlier value left in the
|
||||
/// buffer (live on the Elma cleaner Z33856: work order <c>67504440</c> read back with a stale
|
||||
/// <c>"\0" + "1"</c> tail).</item>
|
||||
/// <item>A STRING is single-byte <b>Windows-1252</b> (umlauts, <c>°</c> in alarm texts), unless
|
||||
/// the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding for STRING").
|
||||
/// ISO-8859-1 is the fallback on a runtime without code-page support — identical outside
|
||||
/// 0x80–0x9F.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal static class TwinCATStringCodec
|
||||
{
|
||||
private static readonly Encoding Windows1252 = CreateWindows1252();
|
||||
|
||||
private static Encoding CreateWindows1252()
|
||||
{
|
||||
try
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
return Encoding.GetEncoding(1252);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Encoding.Latin1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decodes an IEC <c>STRING(n)</c> buffer: ends at the first NUL, Windows-1252 unless <paramref name="utf8"/>.</summary>
|
||||
/// <param name="bytes">The raw symbol bytes (n+1 for a <c>STRING(n)</c>).</param>
|
||||
/// <param name="utf8">True when the target reports UTF-8 string data.</param>
|
||||
/// <returns>The decoded value.</returns>
|
||||
public static string DecodeString(ReadOnlySpan<byte> bytes, bool utf8)
|
||||
{
|
||||
var end = bytes.IndexOf((byte)0);
|
||||
if (end >= 0) bytes = bytes[..end];
|
||||
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary>
|
||||
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
|
||||
/// <returns>The decoded value.</returns>
|
||||
public static string DecodeWString(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var end = bytes.Length & ~1; // whole UTF-16 code units only — a trailing odd byte is ignored
|
||||
for (var i = 0; i + 1 < end; i += 2)
|
||||
{
|
||||
if (bytes[i] == 0 && bytes[i + 1] == 0)
|
||||
{
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Encoding.Unicode.GetString(bytes[..end]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's
|
||||
/// <c>Program:</c> scope prefix. The leading segment is the namespace (POU name /
|
||||
/// GVL name) and subsequent segments walk into struct/array members.</para>
|
||||
/// <para><b>TwinCAT 2 globals</b> are named with ONE leading dot (<c>.OPC_HEARTBEAT</c>,
|
||||
/// <c>.OPC_MACHINEDATA.PROCESSSTATUS</c>) and the PLC requires it — without the dot
|
||||
/// <c>SYM_HNDBYNAME</c> answers 0x710 (symbol not found), observed live on a TC 2.11 CX. The dot
|
||||
/// is kept on the ROOT segment's <see cref="TwinCATSymbolSegment.Name"/> so
|
||||
/// <see cref="ToAdsSymbolName"/> round-trips it unchanged; it is not a separator and never yields
|
||||
/// an empty segment. Exactly one dot: <c>..x</c>, <c>.</c> and <c>.[1]</c> stay invalid.</para>
|
||||
/// </remarks>
|
||||
public sealed record TwinCATSymbolPath(
|
||||
IReadOnlyList<TwinCATSymbolSegment> Segments,
|
||||
@@ -41,6 +47,11 @@ public sealed record TwinCATSymbolPath(
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// A TwinCAT 2 global's single leading dot belongs to the root segment, not before it.
|
||||
// Strip it for the split and put it back on segment 0 below.
|
||||
var tc2Global = src.StartsWith('.');
|
||||
if (tc2Global) src = src[1..];
|
||||
|
||||
var parts = new List<string>();
|
||||
var depth = 0;
|
||||
var start = 0;
|
||||
@@ -91,6 +102,7 @@ public sealed record TwinCATSymbolPath(
|
||||
segments.Add(new TwinCATSymbolSegment(name, subs));
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
if (tc2Global) segments[0] = segments[0] with { Name = "." + segments[0].Name };
|
||||
|
||||
return new TwinCATSymbolPath(segments, bitIndex);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>What kind of value a PLC symbol holds, as far as the type guard is concerned.</summary>
|
||||
internal enum TwinCATPlcKind
|
||||
{
|
||||
/// <summary>A primitive / enum / alias-of-primitive — a single fixed-width value.</summary>
|
||||
Scalar,
|
||||
|
||||
/// <summary>An IEC <c>STRING(n)</c> (any n, incl. an alias such as <c>T_MaxString</c>).</summary>
|
||||
String,
|
||||
|
||||
/// <summary>An IEC <c>WSTRING(n)</c>.</summary>
|
||||
WString,
|
||||
|
||||
/// <summary>A struct / FB / array / pointer / reference / <c>VAR_IN_OUT</c> — no scalar value.</summary>
|
||||
NonScalar,
|
||||
}
|
||||
|
||||
/// <summary>The facts about a PLC symbol the type guard compares a declared type against.</summary>
|
||||
/// <param name="Kind">The symbol's kind.</param>
|
||||
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
|
||||
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
|
||||
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
|
||||
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName)
|
||||
{
|
||||
private static readonly Regex StringTypeName =
|
||||
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a PLC symbol from its (alias-resolved) type name, its Beckhoff
|
||||
/// <see cref="DataTypeCategory"/> when the type system resolved one, and its byte size.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The PLC type name (<c>STRING(80)</c>, <c>INT16</c>, <c>ST_LOGDATA</c>, …).</param>
|
||||
/// <param name="category">The resolved type category, or null when the type system has none.</param>
|
||||
/// <param name="byteSize">The symbol's byte size.</param>
|
||||
/// <param name="dataTypeByteSize">The resolved data type's byte size, when the type system has one.
|
||||
/// It wins over <paramref name="byteSize"/>: on a TwinCAT 2 target the Beckhoff library reports a
|
||||
/// dotted MEMBER symbol's size as if the PLC's byte count were a bit count (STRING(80) → 11,
|
||||
/// INT → 1, UDINT → 1; live on a TC 2.11 CX), while the resolved type carries the true size.</param>
|
||||
/// <param name="symbolTypeName">The symbol's OWN type name, when it differs from the resolved one. A
|
||||
/// TwinCAT 2 <c>VAR_IN_OUT</c> member is named <c>STRING(20) (VAR_IN_OUT)</c> — a 4-byte reference —
|
||||
/// while the library resolves its data type to the plain <c>STRING(20)</c>; the reference marker
|
||||
/// wins, so the symbol is never read as the value it points at.</param>
|
||||
/// <returns>The classified shape.</returns>
|
||||
public static TwinCATPlcSymbolShape Classify(
|
||||
string? typeName, DataTypeCategory? category, int byteSize, int? dataTypeByteSize = null,
|
||||
string? symbolTypeName = null)
|
||||
{
|
||||
if (symbolTypeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| typeName?.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, (symbolTypeName ?? typeName)!.Trim());
|
||||
|
||||
if (dataTypeByteSize is > 0) byteSize = dataTypeByteSize.Value;
|
||||
var name = (typeName ?? string.Empty).Trim();
|
||||
var match = StringTypeName.Match(name);
|
||||
if (match.Success || category == DataTypeCategory.String)
|
||||
{
|
||||
var wide = match.Success
|
||||
? match.Groups[1].Length > 0
|
||||
: name.StartsWith("WSTRING", StringComparison.OrdinalIgnoreCase);
|
||||
return new(wide ? TwinCATPlcKind.WString : TwinCATPlcKind.String, byteSize, false, name);
|
||||
}
|
||||
|
||||
if (category is DataTypeCategory.Array or DataTypeCategory.Struct or DataTypeCategory.FunctionBlock
|
||||
or DataTypeCategory.Program or DataTypeCategory.Pointer or DataTypeCategory.Reference
|
||||
or DataTypeCategory.Union or DataTypeCategory.Interface
|
||||
|| name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, name);
|
||||
|
||||
var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
|
||||
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
|
||||
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A
|
||||
/// refused pair leaves the node Bad (<see cref="TwinCATStatusMapper.BadTypeMismatch"/>) instead of
|
||||
/// publishing the PLC's bytes decoded as the wrong type at Good quality. A <c>null</c> result means
|
||||
/// compatible; otherwise it is a one-line reason for the log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two paths need different rules because they decode differently:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which
|
||||
/// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric
|
||||
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a
|
||||
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item>
|
||||
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
|
||||
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
|
||||
/// reinterprets the bit pattern — both at Good.</item>
|
||||
/// </list>
|
||||
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
|
||||
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
|
||||
/// (<c>DINT</c> vs <c>UDINT</c>, <c>TIME</c> vs a 4-byte integer) is tolerated so a valid config is
|
||||
/// never refused.
|
||||
/// </remarks>
|
||||
internal static class TwinCATTypeGuard
|
||||
{
|
||||
/// <summary>Checks a declared type against a PLC symbol for a symbol-aware READ.</summary>
|
||||
/// <param name="declared">The tag's declared type.</param>
|
||||
/// <param name="plc">The PLC symbol's shape.</param>
|
||||
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
|
||||
public static string? CheckRead(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
|
||||
{
|
||||
var declaredKind = StringKindOf(declared);
|
||||
if (declaredKind is not null)
|
||||
{
|
||||
return plc.Kind == declaredKind
|
||||
? null
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
|
||||
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
|
||||
}
|
||||
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
|
||||
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Checks a declared type against a PLC symbol for a raw-bytes NOTIFICATION.</summary>
|
||||
/// <param name="declared">The tag's declared type.</param>
|
||||
/// <param name="plc">The PLC symbol's shape.</param>
|
||||
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
|
||||
public static string? CheckNotification(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
|
||||
{
|
||||
var reason = CheckRead(declared, plc);
|
||||
if (reason is not null || StringKindOf(declared) is not null) return reason;
|
||||
|
||||
if (plc.Kind == TwinCATPlcKind.NonScalar)
|
||||
return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar";
|
||||
|
||||
var size = DeclaredByteSize(declared);
|
||||
if (size != plc.ByteSize)
|
||||
return $"declared {declared} ({size} B) but the PLC symbol {plc.TypeName} is {plc.ByteSize} B";
|
||||
|
||||
var declaredFloat = declared is TwinCATDataType.Real or TwinCATDataType.LReal;
|
||||
return declaredFloat == plc.IsFloat
|
||||
? null
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
|
||||
}
|
||||
|
||||
/// <summary>The byte width the declared type decodes from a notification buffer.</summary>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The width in bytes.</returns>
|
||||
internal static int DeclaredByteSize(TwinCATDataType type) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool or TwinCATDataType.SInt or TwinCATDataType.USInt => 1,
|
||||
TwinCATDataType.Int or TwinCATDataType.UInt => 2,
|
||||
TwinCATDataType.LInt or TwinCATDataType.ULInt or TwinCATDataType.LReal => 8,
|
||||
_ => 4, // DInt / UDInt / Real / Time / Date / DateTime / TimeOfDay
|
||||
};
|
||||
|
||||
private static TwinCATPlcKind? StringKindOf(TwinCATDataType type) => type switch
|
||||
{
|
||||
TwinCATDataType.String => TwinCATPlcKind.String,
|
||||
TwinCATDataType.WString => TwinCATPlcKind.WString,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
|
||||
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
|
||||
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
|
||||
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATTypeMismatchException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
|
||||
/// <param name="symbolPath">The ADS symbol the tag names.</param>
|
||||
/// <param name="reason">The guard's one-line reason.</param>
|
||||
public TwinCATTypeMismatchException(string symbolPath, string reason)
|
||||
: base($"TwinCAT symbol '{symbolPath}': {reason}")
|
||||
{
|
||||
SymbolPath = symbolPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets the ADS symbol the refused tag names.</summary>
|
||||
public string SymbolPath { get; }
|
||||
}
|
||||
@@ -116,6 +116,9 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public List<FakeNotification> Notifications { get; } = new();
|
||||
/// <summary>Gets or sets a value indicating whether AddNotificationAsync should throw.</summary>
|
||||
public bool ThrowOnAddNotification { get; set; }
|
||||
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
|
||||
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
|
||||
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
|
||||
public int LastMaxDelayMs { get; private set; }
|
||||
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
|
||||
@@ -137,6 +140,8 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
|
||||
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
|
||||
throw perSymbol;
|
||||
|
||||
if (AddNotificationGate is { } gate)
|
||||
await gate.Task.ConfigureAwait(false);
|
||||
|
||||
+45
@@ -128,6 +128,51 @@ public sealed class TwinCATNativeNotificationTests
|
||||
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tag whose declared type cannot decode its PLC symbol (ignitionoee TwinCAT-002/-008) is refused
|
||||
/// by the client with <see cref="TwinCATTypeMismatchException"/>. The refusal is ISOLATED: that
|
||||
/// one tag goes Bad with <c>BadTypeMismatch</c> and every other tag in the same subscribe call
|
||||
/// still registers — before, the exception tore the whole subscription down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Native_subscribe_isolates_a_type_mismatched_tag_to_BadTypeMismatch()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
fake.AddNotificationErrors["MAIN.Order"] =
|
||||
new TwinCATTypeMismatchException("MAIN.Order", "declared DInt but the PLC symbol is the string STRING(3)");
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||
RawTags = TwinCATRawTags.Entries(
|
||||
[
|
||||
new TwinCATTagDefinition("Count", "ads://5.23.91.23.1.1:851", "MAIN.Count", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("Order", "ads://5.23.91.23.1.1:851", "MAIN.Order", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.Real),
|
||||
]),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Count", "Order", "Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
|
||||
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.Count", "MAIN.Speed"], ignoreOrder: true);
|
||||
var refused = events.ShouldHaveSingleItem();
|
||||
refused.FullReference.ShouldBe("Order");
|
||||
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
|
||||
refused.Snapshot.Value.ShouldBeNull();
|
||||
|
||||
// The good tags still deliver, and unsubscribe tears down cleanly.
|
||||
fake.FireNotification("MAIN.Count", 7);
|
||||
events.Last().Snapshot.Value.ShouldBe(7);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
fake.Notifications.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private sealed class AbTagParamsIrrelevant { }
|
||||
|
||||
private sealed class FailAfterNAddsFake : FakeTwinCATClient
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="TwinCATStringCodec"/> — how an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer,
|
||||
/// read at the PLC's own declared width, becomes a value. Ported from the ignitionoee TwinCAT
|
||||
/// driver (0.19.3 first-NUL STRING, 0.19.4 TwinCAT-007 first-NUL WSTRING and TwinCAT-010
|
||||
/// Windows-1252), whose byte fixtures these reuse.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATStringCodecTests
|
||||
{
|
||||
/// <summary>
|
||||
/// An IEC STRING ends at its FIRST NUL; the buffer beyond keeps whatever a longer earlier value
|
||||
/// left there. Live on the Elma cleaner Z33856 (2026-09-24) a decoder that only trimmed trailing
|
||||
/// NULs published work order <c>67504440</c> as <c>"67504440\0" + "1"</c> and part
|
||||
/// <c>110032285</c> as <c>"110032285\0" + "1\0" + "30"</c> — a wrong value at Good quality.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_stops_at_the_first_nul_and_ignores_stale_bytes_after_it()
|
||||
{
|
||||
byte[] workOrder = [(byte)'6', (byte)'7', (byte)'5', (byte)'0', (byte)'4', (byte)'4', (byte)'4', (byte)'0',
|
||||
0x00, (byte)'1', 0x00, (byte)'3', (byte)'0', 0x00];
|
||||
TwinCATStringCodec.DecodeString(workOrder, utf8: false).ShouldBe("67504440");
|
||||
|
||||
byte[] emptied = [0x00, (byte)'O', (byte)'L', (byte)'D'];
|
||||
TwinCATStringCodec.DecodeString(emptied, utf8: false).ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>A buffer with no NUL at all (a full-length value) decodes whole.</summary>
|
||||
[Fact]
|
||||
public void String_without_a_nul_decodes_the_whole_buffer()
|
||||
=> TwinCATStringCodec.DecodeString("ABC"u8.ToArray(), utf8: false).ShouldBe("ABC");
|
||||
|
||||
/// <summary>
|
||||
/// An IEC STRING on TwinCAT is single-byte Windows-1252, not ASCII and not the host's culture
|
||||
/// code page: <c>°</c> (0xB0, an Elma alarm text), <c>ä</c> (0xE4) and <c>€</c> (0x80, the one
|
||||
/// byte where 1252 and ISO-8859-1 differ) must decode to themselves on any host.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_decodes_windows_1252_high_bytes()
|
||||
{
|
||||
byte[] text = [(byte)'8', (byte)'0', 0xB0, (byte)'C', (byte)' ', 0xE4, 0x80, 0x00, 0xFF];
|
||||
TwinCATStringCodec.DecodeString(text, utf8: false).ShouldBe("80°C ä€");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A TwinCAT 3.1 build 4026+ project with "UTF8 Encoding for STRING" on reports UTF-8 string
|
||||
/// data; the codec honours it (still ending at the first NUL).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void String_decodes_utf8_when_the_target_reports_utf8_strings()
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes("80°C ä€").Concat(new byte[] { 0x00, (byte)'X' }).ToArray();
|
||||
TwinCATStringCodec.DecodeString(bytes, utf8: true).ShouldBe("80°C ä€");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A WSTRING ends at its first ALIGNED <c>U+0000</c> code unit (TwinCAT-007): <c>"NEW\0LD"</c>
|
||||
/// must publish <c>"NEW"</c>. A zero HIGH byte of a non-NUL unit (every ASCII character in
|
||||
/// UTF-16LE) is not a terminator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WString_stops_at_the_first_aligned_nul_code_unit()
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes("NEW\0LD\0");
|
||||
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("NEW");
|
||||
TwinCATStringCodec.DecodeWString(Encoding.Unicode.GetBytes("Grüße")).ShouldBe("Grüße");
|
||||
}
|
||||
|
||||
/// <summary>A trailing odd byte is not half a character — it is ignored.</summary>
|
||||
[Fact]
|
||||
public void WString_ignores_a_trailing_odd_byte()
|
||||
{
|
||||
var bytes = Encoding.Unicode.GetBytes("AB").Concat(new byte[] { 0x43 }).ToArray();
|
||||
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("AB");
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,12 @@ public sealed class TwinCATSymbolPathTests
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(".Motor")] // leading dot
|
||||
[InlineData(".")] // a bare dot names nothing
|
||||
[InlineData("..Motor")] // TwinCAT 2 globals carry ONE leading dot, never two
|
||||
[InlineData(".[1]")] // leading dot then no identifier
|
||||
[InlineData(".9x")] // leading dot then an ident starting with a digit
|
||||
[InlineData(".x.")] // leading dot, trailing dot
|
||||
[InlineData(" . x")] // whitespace between the dot and the identifier
|
||||
[InlineData("Motor.")] // trailing dot
|
||||
[InlineData("Motor.[0]")] // empty segment
|
||||
[InlineData("1bad")] // ident starts with digit
|
||||
@@ -148,4 +153,42 @@ public sealed class TwinCATSymbolPathTests
|
||||
parsed.ToAdsSymbolName().ShouldBe(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT 2 names a GLOBAL variable with ONE leading dot (<c>.OPC_HEARTBEAT</c>) and the PLC
|
||||
/// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) <c>SYM_HNDBYNAME</c>
|
||||
/// answered a handle for <c>.OPC_HEARTBEAT</c> and 0x710 (symbol not found) for
|
||||
/// <c>OPC_HEARTBEAT</c>. The parser rejected the dot, so a TC2 global fell back to the raw
|
||||
/// string — losing bit-index parsing (<c>.X.WORD.3</c> was sent to ADS verbatim).
|
||||
/// Ported from ignitionoee driver 0.19.1.
|
||||
/// </summary>
|
||||
/// <param name="input">A TwinCAT 2 global path as the PLC names it.</param>
|
||||
[Theory]
|
||||
[InlineData(".x")]
|
||||
[InlineData(".OPC_HEARTBEAT")]
|
||||
[InlineData(".OPC_MACHINEDATA.PROCESSSTATUS")]
|
||||
[InlineData(".ARRCARRIERDATA_V[1].SWORKORDER")]
|
||||
[InlineData(".OPC_LOGDATA.STATIONDATA[5].TEMPERATUREMAXIMUM")]
|
||||
public void TwinCAT2_leading_dot_global_parses_and_keeps_its_dot(string input)
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse(input);
|
||||
p.ShouldNotBeNull(input);
|
||||
p.Segments[0].Name.ShouldStartWith(".");
|
||||
p.ToAdsSymbolName().ShouldBe(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses:
|
||||
/// the parent word is read as <c>.OPC_STATUS.WORD</c> and bit 3 is extracted locally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TwinCAT2_global_bit_access_parses_with_the_dot_on_the_root_segment()
|
||||
{
|
||||
var p = TwinCATSymbolPath.TryParse(".OPC_STATUS.WORD.3");
|
||||
p.ShouldNotBeNull();
|
||||
p.Segments.Select(s => s.Name).ShouldBe([".OPC_STATUS", "WORD"]);
|
||||
p.BitIndex.ShouldBe(3);
|
||||
(p with { BitIndex = null }).ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD");
|
||||
p.ToAdsSymbolName().ShouldBe(".OPC_STATUS.WORD.3");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <c>AdsTwinCATClient.MapSymbolTypeName</c> — the symbol-browse type mapper — against the type
|
||||
/// names a real TwinCAT 2 PLC reports. A name the mapper does not know drops the symbol from
|
||||
/// discovery silently, so every TC2 spelling below was a symbol the browse never offered.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATSymbolTypeNameTests
|
||||
{
|
||||
/// <summary>
|
||||
/// TwinCAT 2 reports top-level integer globals by their ADS base-type name, not the IEC name:
|
||||
/// the Elma cleaner Z33856 (TC 2.11, symbol upload 2026-09-24) lists 233 symbols as
|
||||
/// <c>INT16</c> and 121 as <c>UINT16</c> (e.g. <c>.ACTUALOPERATIONMODE</c>). The Beckhoff
|
||||
/// type system carries these as built-in primitives (<c>AdsSymbolProvider.CreateBuildInTypes</c>:
|
||||
/// INT16, UINT16, INT32, UINT32, UINT8, FLOAT, DOUBLE), so <c>IDataType.Name</c> arrives spelled
|
||||
/// that way and must map like its IEC twin.
|
||||
/// </summary>
|
||||
/// <param name="typeName">The type name as the PLC / Beckhoff type system reports it.</param>
|
||||
/// <param name="expected">The driver atomic type it must map to.</param>
|
||||
[Theory]
|
||||
[InlineData("INT16", TwinCATDataType.Int)]
|
||||
[InlineData("UINT16", TwinCATDataType.UInt)]
|
||||
[InlineData("INT32", TwinCATDataType.DInt)]
|
||||
[InlineData("UINT32", TwinCATDataType.UDInt)]
|
||||
[InlineData("UINT8", TwinCATDataType.USInt)]
|
||||
[InlineData("FLOAT", TwinCATDataType.Real)]
|
||||
[InlineData("DOUBLE", TwinCATDataType.LReal)]
|
||||
public void TwinCAT2_ads_base_type_names_map_like_their_iec_twins(string typeName, TwinCATDataType expected)
|
||||
=> AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// The same TC2 data-type upload spells struct members with a trailing blank — <c>"REAL "</c>
|
||||
/// (54 members on Z33856) and <c>"DINT "</c> (24) — so the name is trimmed before matching.
|
||||
/// </summary>
|
||||
/// <param name="typeName">A padded type name.</param>
|
||||
/// <param name="expected">The driver atomic type it must map to.</param>
|
||||
[Theory]
|
||||
[InlineData("REAL ", TwinCATDataType.Real)]
|
||||
[InlineData("DINT ", TwinCATDataType.DInt)]
|
||||
[InlineData(" STRING(80) ", TwinCATDataType.String)]
|
||||
public void Padded_type_names_are_trimmed(string typeName, TwinCATDataType expected)
|
||||
=> AdsTwinCATClient.MapSymbolTypeName(typeName).ShouldBe(expected);
|
||||
|
||||
/// <summary>The IEC spellings, sized strings and unsupported names are unchanged.</summary>
|
||||
[Fact]
|
||||
public void Existing_iec_mappings_are_unchanged()
|
||||
{
|
||||
AdsTwinCATClient.MapSymbolTypeName("INT").ShouldBe(TwinCATDataType.Int);
|
||||
AdsTwinCATClient.MapSymbolTypeName("STRING(8)").ShouldBe(TwinCATDataType.String);
|
||||
AdsTwinCATClient.MapSymbolTypeName("WSTRING(20)").ShouldBe(TwinCATDataType.WString);
|
||||
AdsTwinCATClient.MapSymbolTypeName("ST_LOGDATA").ShouldBeNull();
|
||||
AdsTwinCATClient.MapSymbolTypeName("INT (VAR_IN_OUT)").ShouldBeNull();
|
||||
AdsTwinCATClient.MapSymbolTypeName(null).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Shouldly;
|
||||
using TwinCAT.TypeSystem;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="TwinCATTypeGuard"/> — whether a tag's DECLARED <see cref="TwinCATDataType"/> may
|
||||
/// decode the PLC symbol it names. Ported from the ignitionoee TwinCAT driver's extra-type
|
||||
/// reconciliation (TwinCAT-002 width / float kind, TwinCAT-008 string kind): a refused pair leaves
|
||||
/// the node Bad (<c>BadTypeMismatch</c>) instead of publishing the PLC's bytes decoded as the wrong
|
||||
/// type at Good quality.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATTypeGuardTests
|
||||
{
|
||||
private static TwinCATPlcSymbolShape Plc(string typeName, int byteSize, DataTypeCategory? category = null)
|
||||
=> TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
|
||||
|
||||
// ---- classification ----
|
||||
|
||||
/// <summary>The PLC's type name / category classifies into the four kinds the guard reasons about.</summary>
|
||||
[Fact]
|
||||
public void Classify_recognises_string_kinds_scalars_and_containers()
|
||||
{
|
||||
Plc("STRING(80)", 81).Kind.ShouldBe(TwinCATPlcKind.String);
|
||||
Plc("STRING", 81).Kind.ShouldBe(TwinCATPlcKind.String);
|
||||
Plc("WSTRING(20)", 42).Kind.ShouldBe(TwinCATPlcKind.WString);
|
||||
Plc("T_MaxString", 256, DataTypeCategory.String).Kind.ShouldBe(TwinCATPlcKind.String);
|
||||
Plc("STRING_DATA", 40).Kind.ShouldBe(TwinCATPlcKind.Scalar); // a UDT named STRING_… is not a string
|
||||
Plc("ST_LOGDATA", 986, DataTypeCategory.Struct).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
|
||||
Plc("ARRAY [0..12] OF DINT", 52).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
|
||||
Plc("INT (VAR_IN_OUT)", 4).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
|
||||
Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
|
||||
Plc("REAL", 4).IsFloat.ShouldBeTrue();
|
||||
Plc("INT16", 2).IsFloat.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live on the TwinCAT 2.11 Elma cleaner Z33856 (2026-10-07): for a dotted MEMBER path the
|
||||
/// Beckhoff library reports the symbol's <c>ByteSize</c> as if the PLC's byte count were a bit
|
||||
/// count — <c>.OPC_MACHINEDATA.PROCESSSTATUS</c> (STRING(80)) came back as 11 bytes, an
|
||||
/// <c>INT</c> member as 1, a <c>UDINT</c> as 1 — while its resolved <c>DataType.ByteSize</c> was
|
||||
/// the true 81 / 2 / 4 (raw <c>SYM_INFOBYNAMEEX</c> answers 81). Sizing a string read by the
|
||||
/// symbol truncated <c>"ready for operation"</c> to <c>"ready for o"</c> at Good, and the
|
||||
/// notification width check refused every numeric member. The resolved type's size wins.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Classify_prefers_the_resolved_data_type_size_over_the_symbol_size()
|
||||
{
|
||||
TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, byteSize: 11, dataTypeByteSize: 81)
|
||||
.ByteSize.ShouldBe(81);
|
||||
var member = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 2);
|
||||
member.ByteSize.ShouldBe(2);
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, member).ShouldBeNull();
|
||||
TwinCATPlcSymbolShape.Classify("UDINT", DataTypeCategory.Primitive, byteSize: 1, dataTypeByteSize: 4).ByteSize.ShouldBe(4);
|
||||
|
||||
// No resolved type (or a zero size) falls back to the symbol's own size.
|
||||
TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: null).ByteSize.ShouldBe(31);
|
||||
TwinCATPlcSymbolShape.Classify("STRING(30)", null, byteSize: 31, dataTypeByteSize: 0).ByteSize.ShouldBe(31);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live on Z33856 (2026-10-07): <c>P_INITINPUT_ACTION.SLOTID</c> is a TwinCAT 2
|
||||
/// <c>STRING(20) (VAR_IN_OUT)</c> — a 4-byte reference — but the Beckhoff library resolves its
|
||||
/// <c>DataType</c> to the plain <c>STRING(20)</c> (category String). Classifying by the resolved
|
||||
/// type alone let a String tag read 21 bytes starting at the pointer and publish them as text at
|
||||
/// Good (<c>"n±\u008dâa»‰â…"</c>). The symbol's own type name marks it a reference, and that wins.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Classify_treats_a_var_in_out_symbol_as_non_scalar_even_when_its_resolved_type_is_a_string()
|
||||
{
|
||||
var shape = TwinCATPlcSymbolShape.Classify("STRING(20)", DataTypeCategory.String, byteSize: 4,
|
||||
dataTypeByteSize: 21, symbolTypeName: "STRING(20) (VAR_IN_OUT)");
|
||||
shape.Kind.ShouldBe(TwinCATPlcKind.NonScalar);
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, shape).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, shape).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ---- read path (symbol-aware: only the string kind can go wrong) ----
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT-008: before the fix only width separated a string from a number, so a numeric declared
|
||||
/// over a PLC string decoded the string's bytes as a number at Good. The Beckhoff read path hands
|
||||
/// a declared-numeric tag over a PLC <c>STRING(3)</c> back a <c>string</c> object at Good — refused.
|
||||
/// </summary>
|
||||
/// <param name="declared">The tag's declared type.</param>
|
||||
[Theory]
|
||||
[InlineData(TwinCATDataType.DInt)]
|
||||
[InlineData(TwinCATDataType.UDInt)]
|
||||
[InlineData(TwinCATDataType.Time)]
|
||||
[InlineData(TwinCATDataType.Int)]
|
||||
[InlineData(TwinCATDataType.LInt)]
|
||||
public void Read_refuses_a_numeric_declared_over_a_plc_string(TwinCATDataType declared)
|
||||
=> TwinCATTypeGuard.CheckRead(declared, Plc("STRING(3)", 4)).ShouldNotBeNull();
|
||||
|
||||
/// <summary>A string declared over a number, the other string kind, or a container is refused.</summary>
|
||||
[Fact]
|
||||
public void Read_refuses_a_string_over_anything_but_the_same_string_kind()
|
||||
{
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("DINT", 4)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("STRING(161)", 162)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("WSTRING(80)", 162)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
|
||||
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Read_accepts_same_kind_strings_of_any_length_and_numeric_pairs()
|
||||
{
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(8)", 9)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(255)", 256)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("E_STATE", 2, DataTypeCategory.Enum)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- notification path (raw bytes decoded as the declared type) ----
|
||||
|
||||
/// <summary>
|
||||
/// TwinCAT-002: an ADS notification carries raw bytes decoded as the DECLARED type, so a same-width
|
||||
/// float/integer clash reinterprets the bit pattern (DINT over REAL) and a width mismatch truncates
|
||||
/// (DINT over LINT) — both at Good before the guard.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Notification_refuses_width_and_float_kind_mismatches()
|
||||
{
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("DINT", 4)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("LINT", 8)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.LReal, Plc("REAL", 4)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("ARRAY [0..12] OF DINT", 52)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("STRING(3)", 4)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A same-width, same-kind divergence (DINT vs UDINT, TIME vs a 4-byte integer, a TC2 INT16) is
|
||||
/// tolerated so a valid config is never refused; strings of the same kind match at any length.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Notification_tolerates_same_width_same_kind_and_same_kind_strings()
|
||||
{
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.UDInt, Plc("DINT", 4)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Time, Plc("UDINT", 4)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, Plc("INT16", 2)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Bool, Plc("BOOL", 1)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("REAL", 4)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, Plc("STRING(14)", 15)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotification(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user