diff --git a/docs/Driver.TwinCAT.Cli.md b/docs/Driver.TwinCAT.Cli.md
index 4633952c..a5cccf3d 100644
--- a/docs/Driver.TwinCAT.Cli.md
+++ b/docs/Driver.TwinCAT.Cli.md
@@ -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
diff --git a/docs/drivers/TwinCAT.md b/docs/drivers/TwinCAT.md
index 081f8752..f4ac2c7c 100644
--- a/docs/drivers/TwinCAT.md
+++ b/docs/drivers/TwinCAT.md
@@ -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
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
index 22e23894..96fe1d87 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
@@ -14,7 +14,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
/// Gets or sets the TwinCAT symbol path to read.
[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!;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index 385b37e6..07057e61 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -41,6 +41,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
+ private bool? _stringsAreUtf8;
///
/// Initializes a new instance of the 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
}
}
+ ///
+ /// Resolves the PLC symbol behind into the facts
+ /// compares a declared type against. Uses the Beckhoff symbol
+ /// cache (lookup on), so repeat calls are local.
+ ///
+ 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);
+ }
+
+ ///
+ /// Reads exactly 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.
+ ///
+ 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 */ }
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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
///
public Action OnChange { get; } = onChange;
+ ///
+ /// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
+ ///
+ public bool StringsUtf8 { get; init; }
+
///
/// Disposes the notification subscription.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
index 0e992dfe..76c8665d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
@@ -94,6 +94,9 @@ public interface ITwinCATClient : IDisposable
/// Invoked with (symbolPath, boxedValue) per notification.
/// Cancels the initial registration; does not tear down an established notification.
/// A handle whose disposal tears the registered notification down.
+ /// The declared 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.
Task AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index d79a5522..c2288c90 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -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;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs
new file mode 100644
index 00000000..d3a90c1f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs
@@ -0,0 +1,69 @@
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+///
+/// Decodes an IEC STRING(n) / WSTRING(n) 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 (CultureInfo.CurrentCulture.TextInfo.ANSICodePage) for a non-UTF-8 target — so a
+/// TwinCAT 2 string would decode differently on a Polish (1250) or Cyrillic (1251) server.
+///
+///
+/// Two rules, both ported from the ignitionoee TwinCAT driver and both found on a real PLC:
+///
+/// - A value ENDS at its first NUL — 0x00 for a STRING, an aligned 0x00 0x00
+/// 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 67504440 read back with a stale
+/// "\0" + "1" tail).
+/// - A STRING is single-byte Windows-1252 (umlauts, ° 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.
+///
+///
+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;
+ }
+ }
+
+ /// Decodes an IEC STRING(n) buffer: ends at the first NUL, Windows-1252 unless .
+ /// The raw symbol bytes (n+1 for a STRING(n)).
+ /// True when the target reports UTF-8 string data.
+ /// The decoded value.
+ public static string DecodeString(ReadOnlySpan bytes, bool utf8)
+ {
+ var end = bytes.IndexOf((byte)0);
+ if (end >= 0) bytes = bytes[..end];
+ return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
+ }
+
+ /// Decodes an IEC WSTRING(n) buffer (UTF-16LE): ends at the first aligned U+0000.
+ /// The raw symbol bytes (2(n+1) for a WSTRING(n)).
+ /// The decoded value.
+ public static string DecodeWString(ReadOnlySpan 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]);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
index 178773cf..e57284a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATSymbolPath.cs
@@ -11,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// grammar maps cleanly onto the AbCip Logix path parser, but without Logix's
/// Program: scope prefix. The leading segment is the namespace (POU name /
/// GVL name) and subsequent segments walk into struct/array members.
+/// TwinCAT 2 globals are named with ONE leading dot (.OPC_HEARTBEAT,
+/// .OPC_MACHINEDATA.PROCESSSTATUS) and the PLC requires it — without the dot
+/// SYM_HNDBYNAME answers 0x710 (symbol not found), observed live on a TC 2.11 CX. The dot
+/// is kept on the ROOT segment's so
+/// round-trips it unchanged; it is not a separator and never yields
+/// an empty segment. Exactly one dot: ..x, . and .[1] stay invalid.
///
public sealed record TwinCATSymbolPath(
IReadOnlyList 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();
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);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs
new file mode 100644
index 00000000..5fa6d4cf
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs
@@ -0,0 +1,164 @@
+using System.Text.RegularExpressions;
+using TwinCAT.TypeSystem;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+/// What kind of value a PLC symbol holds, as far as the type guard is concerned.
+internal enum TwinCATPlcKind
+{
+ /// A primitive / enum / alias-of-primitive — a single fixed-width value.
+ Scalar,
+
+ /// An IEC STRING(n) (any n, incl. an alias such as T_MaxString).
+ String,
+
+ /// An IEC WSTRING(n).
+ WString,
+
+ /// A struct / FB / array / pointer / reference / VAR_IN_OUT — no scalar value.
+ NonScalar,
+}
+
+/// The facts about a PLC symbol the type guard compares a declared type against.
+/// The symbol's kind.
+/// The symbol's byte size as the PLC reports it.
+/// True for a REAL / LREAL scalar.
+/// The (alias-resolved) PLC type name, for log messages.
+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);
+
+ ///
+ /// Classifies a PLC symbol from its (alias-resolved) type name, its Beckhoff
+ /// when the type system resolved one, and its byte size.
+ ///
+ /// The PLC type name (STRING(80), INT16, ST_LOGDATA, …).
+ /// The resolved type category, or null when the type system has none.
+ /// The symbol's byte size.
+ /// The resolved data type's byte size, when the type system has one.
+ /// It wins over : 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.
+ /// The symbol's OWN type name, when it differs from the resolved one. A
+ /// TwinCAT 2 VAR_IN_OUT member is named STRING(20) (VAR_IN_OUT) — a 4-byte reference —
+ /// while the library resolves its data type to the plain STRING(20); the reference marker
+ /// wins, so the symbol is never read as the value it points at.
+ /// The classified shape.
+ 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);
+ }
+}
+
+///
+/// Decides whether a tag's DECLARED 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 () instead of
+/// publishing the PLC's bytes decoded as the wrong type at Good quality. A null result means
+/// compatible; otherwise it is a one-line reason for the log.
+///
+///
+/// The two paths need different rules because they decode differently:
+///
+/// - Read () 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 string object back at Good.
+/// - Notification () 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.
+///
+/// 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
+/// (DINT vs UDINT, TIME vs a 4-byte integer) is tolerated so a valid config is
+/// never refused.
+///
+internal static class TwinCATTypeGuard
+{
+ /// Checks a declared type against a PLC symbol for a symbol-aware READ.
+ /// The tag's declared type.
+ /// The PLC symbol's shape.
+ /// null when compatible; otherwise the reason.
+ 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;
+ }
+
+ /// Checks a declared type against a PLC symbol for a raw-bytes NOTIFICATION.
+ /// The tag's declared type.
+ /// The PLC symbol's shape.
+ /// null when compatible; otherwise the reason.
+ 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";
+ }
+
+ /// The byte width the declared type decodes from a notification buffer.
+ /// The declared type.
+ /// The width in bytes.
+ 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,
+ };
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs
new file mode 100644
index 00000000..2dc981c3
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeMismatchException.cs
@@ -0,0 +1,22 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+///
+/// Thrown by when a tag's declared
+/// cannot decode the PLC symbol it names (see
+/// ). The driver isolates the one tag to
+/// and keeps the rest of the subscription.
+///
+internal sealed class TwinCATTypeMismatchException : Exception
+{
+ /// Initializes a new instance of the class.
+ /// The ADS symbol the tag names.
+ /// The guard's one-line reason.
+ public TwinCATTypeMismatchException(string symbolPath, string reason)
+ : base($"TwinCAT symbol '{symbolPath}': {reason}")
+ {
+ SymbolPath = symbolPath;
+ }
+
+ /// Gets the ADS symbol the refused tag names.
+ public string SymbolPath { get; }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
index ff5c6a13..40c4c70c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
@@ -116,6 +116,9 @@ internal class FakeTwinCATClient : ITwinCATClient
public List Notifications { get; } = new();
/// Gets or sets a value indicating whether AddNotificationAsync should throw.
public bool ThrowOnAddNotification { get; set; }
+ /// Per-symbol exceptions throws (e.g. a
+ /// for one refused tag).
+ public Dictionary AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
/// Records the most recently-supplied maxDelayMs for Driver.TwinCAT-014 tests.
public int LastMaxDelayMs { get; private set; }
/// When set, 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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
index 1889144d..3e2bb9ff 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
@@ -128,6 +128,51 @@ public sealed class TwinCATNativeNotificationTests
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
}
+ ///
+ /// A tag whose declared type cannot decode its PLC symbol (ignitionoee TwinCAT-002/-008) is refused
+ /// by the client with . The refusal is ISOLATED: that
+ /// one tag goes Bad with BadTypeMismatch and every other tag in the same subscribe call
+ /// still registers — before, the exception tore the whole subscription down.
+ ///
+ [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();
+ 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
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATStringCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATStringCodecTests.cs
new file mode 100644
index 00000000..c890b89d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATStringCodecTests.cs
@@ -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;
+
+///
+/// Pins — how an IEC STRING(n) / WSTRING(n) 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.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATStringCodecTests
+{
+ ///
+ /// 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 67504440 as "67504440\0" + "1" and part
+ /// 110032285 as "110032285\0" + "1\0" + "30" — a wrong value at Good quality.
+ ///
+ [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("");
+ }
+
+ /// A buffer with no NUL at all (a full-length value) decodes whole.
+ [Fact]
+ public void String_without_a_nul_decodes_the_whole_buffer()
+ => TwinCATStringCodec.DecodeString("ABC"u8.ToArray(), utf8: false).ShouldBe("ABC");
+
+ ///
+ /// An IEC STRING on TwinCAT is single-byte Windows-1252, not ASCII and not the host's culture
+ /// code page: ° (0xB0, an Elma alarm text), ä (0xE4) and € (0x80, the one
+ /// byte where 1252 and ISO-8859-1 differ) must decode to themselves on any host.
+ ///
+ [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 ä€");
+ }
+
+ ///
+ /// 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).
+ ///
+ [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 ä€");
+ }
+
+ ///
+ /// A WSTRING ends at its first ALIGNED U+0000 code unit (TwinCAT-007): "NEW\0LD"
+ /// must publish "NEW". A zero HIGH byte of a non-NUL unit (every ASCII character in
+ /// UTF-16LE) is not a terminator.
+ ///
+ [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");
+ }
+
+ /// A trailing odd byte is not half a character — it is ignored.
+ [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");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs
index f2c9a9a6..2d892b30 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolPathTests.cs
@@ -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);
}
}
+
+ ///
+ /// TwinCAT 2 names a GLOBAL variable with ONE leading dot (.OPC_HEARTBEAT) and the PLC
+ /// requires it: on a real TC 2.11 CX (Elma cleaner Z33856, 2026-09-24) SYM_HNDBYNAME
+ /// answered a handle for .OPC_HEARTBEAT and 0x710 (symbol not found) for
+ /// OPC_HEARTBEAT. The parser rejected the dot, so a TC2 global fell back to the raw
+ /// string — losing bit-index parsing (.X.WORD.3 was sent to ADS verbatim).
+ /// Ported from ignitionoee driver 0.19.1.
+ ///
+ /// A TwinCAT 2 global path as the PLC names it.
+ [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);
+ }
+
+ ///
+ /// The leading dot belongs to the ROOT segment only, so bit access on a TC2 global still parses:
+ /// the parent word is read as .OPC_STATUS.WORD and bit 3 is extracted locally.
+ ///
+ [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");
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs
new file mode 100644
index 00000000..177bc036
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolTypeNameTests.cs
@@ -0,0 +1,60 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// Pins AdsTwinCATClient.MapSymbolTypeName — 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.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATSymbolTypeNameTests
+{
+ ///
+ /// 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
+ /// INT16 and 121 as UINT16 (e.g. .ACTUALOPERATIONMODE). The Beckhoff
+ /// type system carries these as built-in primitives (AdsSymbolProvider.CreateBuildInTypes:
+ /// INT16, UINT16, INT32, UINT32, UINT8, FLOAT, DOUBLE), so IDataType.Name arrives spelled
+ /// that way and must map like its IEC twin.
+ ///
+ /// The type name as the PLC / Beckhoff type system reports it.
+ /// The driver atomic type it must map to.
+ [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);
+
+ ///
+ /// The same TC2 data-type upload spells struct members with a trailing blank — "REAL "
+ /// (54 members on Z33856) and "DINT " (24) — so the name is trimmed before matching.
+ ///
+ /// A padded type name.
+ /// The driver atomic type it must map to.
+ [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);
+
+ /// The IEC spellings, sized strings and unsupported names are unchanged.
+ [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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs
new file mode 100644
index 00000000..e83ced13
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs
@@ -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;
+
+///
+/// Pins — whether a tag's DECLARED 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 (BadTypeMismatch) instead of publishing the PLC's bytes decoded as the wrong
+/// type at Good quality.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATTypeGuardTests
+{
+ private static TwinCATPlcSymbolShape Plc(string typeName, int byteSize, DataTypeCategory? category = null)
+ => TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
+
+ // ---- classification ----
+
+ /// The PLC's type name / category classifies into the four kinds the guard reasons about.
+ [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();
+ }
+
+ ///
+ /// Live on the TwinCAT 2.11 Elma cleaner Z33856 (2026-10-07): for a dotted MEMBER path the
+ /// Beckhoff library reports the symbol's ByteSize as if the PLC's byte count were a bit
+ /// count — .OPC_MACHINEDATA.PROCESSSTATUS (STRING(80)) came back as 11 bytes, an
+ /// INT member as 1, a UDINT as 1 — while its resolved DataType.ByteSize was
+ /// the true 81 / 2 / 4 (raw SYM_INFOBYNAMEEX answers 81). Sizing a string read by the
+ /// symbol truncated "ready for operation" to "ready for o" at Good, and the
+ /// notification width check refused every numeric member. The resolved type's size wins.
+ ///
+ [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);
+ }
+
+ ///
+ /// Live on Z33856 (2026-10-07): P_INITINPUT_ACTION.SLOTID is a TwinCAT 2
+ /// STRING(20) (VAR_IN_OUT) — a 4-byte reference — but the Beckhoff library resolves its
+ /// DataType to the plain STRING(20) (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 ("n±\u008dâa»‰â…"). The symbol's own type name marks it a reference, and that wins.
+ ///
+ [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) ----
+
+ ///
+ /// 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 STRING(3) back a string object at Good — refused.
+ ///
+ /// The tag's declared type.
+ [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();
+
+ /// A string declared over a number, the other string kind, or a container is refused.
+ [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();
+ }
+
+ ///
+ /// 0.19.2: a STRING / WSTRING tag matches a PLC STRING(n) / WSTRING(n) of ANY length.
+ /// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
+ ///
+ [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) ----
+
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+ }
+}