Merge TwinCAT fixes ported from ignitionoee + live-validated on the Elma PLCs
v2-ci / build (push) Successful in 4m41s
v2-ci / unit-tests (push) Failing after 16m28s

This commit is contained in:
Joseph Doherty
2026-10-07 14:28:01 -04:00
16 changed files with 959 additions and 26 deletions
+1 -1
View File
@@ -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
View File
@@ -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);
@@ -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();
}
}