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 245ce4c8..3b0a23cd 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,27 @@ 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, 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.
+ if (type is TwinCATDataType.String or TwinCATDataType.WString)
+ {
+ var raw = await _client.ReadValueAsync(symbolPath, typeof(byte[]), cancellationToken)
+ .ConfigureAwait(false);
+ if (raw.ErrorCode != AdsErrorCode.NoError)
+ return (null, MapAndSignal((uint)raw.ErrorCode));
+ return (DecodeString(type, raw.Value as byte[] ?? [], StringsAreUtf8()), TwinCATStatusMapper.Good);
+ }
+
var clrType = MapToClrType(type);
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
.ConfigureAwait(false);
@@ -140,6 +162,51 @@ 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, uint status)> DescribeSymbolAsync(
+ string symbolPath, CancellationToken cancellationToken)
+ {
+ var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
+ if (result.ErrorCode != AdsErrorCode.NoError)
+ return (default, MapAndSignal((uint)result.ErrorCode));
+ if (result.Value is not { } symbol)
+ return (default, TwinCATStatusMapper.BadNodeIdUnknown);
+
+ // Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type.
+ var dataType = symbol.DataType?.Resolve();
+ return (TwinCATPlcSymbolShape.Classify(dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize),
+ TwinCATStatusMapper.Good);
+ }
+
+ ///
+ /// 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 +264,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 +298,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 +324,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));
}
@@ -472,6 +571,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..9f6c6ac9 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} " +
+ "does not match the PLC symbol '{Symbol}' (string vs numeric)",
+ _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/TwinCATTypeGuard.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs
new file mode 100644
index 00000000..263551b5
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATTypeGuard.cs
@@ -0,0 +1,149 @@
+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 classified shape.
+ public static TwinCATPlcSymbolShape Classify(string? typeName, DataTypeCategory? category, int byteSize)
+ {
+ 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/TwinCATTypeGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs
new file mode 100644
index 00000000..1aa965d6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTypeGuardTests.cs
@@ -0,0 +1,114 @@
+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();
+ }
+
+ // ---- 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();
+ }
+}