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(); + } +}