fix(twincat): string subscriptions, Windows-1252 STRING decode, and declared-type kind checks (ignitionoee 0.19.2-0.19.4)

Port of the ignitionoee TwinCAT string + type-reconciliation fixes onto the
Beckhoff-library client:

- STRING/WSTRING subscriptions never worked: AddDeviceNotificationExAsync was
  called with typeof(string) and no length, and the Beckhoff any-type
  marshaller throws ArgumentNullException for a string without one, so one
  string tag tore down its whole subscribe call. A string now registers the
  PLC's own byte width as raw bytes (0.19.2: STRING(n) of any n).
- Strings are read as raw bytes and decoded by TwinCATStringCodec. The
  Beckhoff marshaller already cut a read at the first NUL (0.19.3 / TwinCAT-007
  were not defects on the read path); the codec keeps that rule for both STRING
  and WSTRING and extends it to the new notification path. A STRING now decodes
  as Windows-1252 (TwinCAT-010) - or UTF-8 when the target reports UTF-8
  strings - instead of the host culture's ANSI code page the Beckhoff
  marshaller falls back to (1250 on a Polish host, 1251 Cyrillic, ...).
- TwinCATTypeGuard (TwinCAT-002/-008): a read refuses a numeric tag over a PLC
  string (and vice versa / STRING vs WSTRING / string over a container) with
  BadTypeMismatch instead of handing back a string object at Good; a
  notification - raw bytes decoded as the DECLARED type - additionally refuses
  width and float/integer mismatches (DINT over REAL reinterpreted the bits).
- A refused notification is isolated: that tag publishes BadTypeMismatch with
  one WARN, keeps a dead intent for the probe-tick retry, and the rest of the
  subscription registers.
This commit is contained in:
Joseph Doherty
2026-10-07 14:05:46 -04:00
parent 1a74c3c2e0
commit 83a314e734
10 changed files with 634 additions and 9 deletions
@@ -41,6 +41,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
private readonly CancellationTokenSource _dispatchCts = new();
private readonly Task _dispatchLoop;
private int _disposed;
private bool? _stringsAreUtf8;
/// <summary>
/// Initializes a new instance of the <see cref="AdsTwinCATClient"/> class.
@@ -125,6 +126,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
}
}
/// <summary>
/// Resolves the PLC symbol behind <paramref name="symbolPath"/> into the facts
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
/// </summary>
private async Task<(TwinCATPlcSymbolShape shape, 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);
}
/// <summary>
/// True when the target reports UTF-8 STRING data (TwinCAT 3.1 4026+ "UTF8 Encoding for
/// STRING"); otherwise a STRING is Windows-1252. Read once per client from the symbol upload
/// info the Beckhoff client already fetches.
/// </summary>
private bool StringsAreUtf8()
{
if (_stringsAreUtf8 is bool known) return known;
try
{
var utf8 = _client.DefaultValueEncoding.CodePage == System.Text.Encoding.UTF8.CodePage;
_stringsAreUtf8 = utf8;
return utf8;
}
catch
{
return false; // not determinable yet (e.g. not connected) — Windows-1252, retry next time
}
}
private static string DecodeString(TwinCATDataType type, byte[] bytes, bool utf8) =>
type == TwinCATDataType.WString
? TwinCATStringCodec.DecodeWString(bytes)
: TwinCATStringCodec.DecodeString(bytes, utf8);
private static string StripBitSuffix(string symbolPath)
{
var lastDot = symbolPath.LastIndexOf('.');
@@ -197,6 +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
/// </summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>
/// Gets a value indicating whether a STRING notification decodes as UTF-8 (else Windows-1252).
/// </summary>
public bool StringsUtf8 { get; init; }
/// <summary>
/// Disposes the notification subscription.
/// </summary>
@@ -94,6 +94,9 @@ public interface ITwinCATClient : IDisposable
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns>
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
@@ -276,9 +276,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Healthy, now, null);
else
{
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
_driverInstanceId, status, reference);
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"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;
}
@@ -0,0 +1,69 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Decodes an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer read at the PLC's own declared width.
/// The driver reads string symbols as raw bytes and decodes them here instead of letting the
/// Beckhoff marshaller pick an encoding, because that marshaller falls back to the HOST's ANSI code
/// page (<c>CultureInfo.CurrentCulture.TextInfo.ANSICodePage</c>) for a non-UTF-8 target — so a
/// TwinCAT 2 string would decode differently on a Polish (1250) or Cyrillic (1251) server.
/// </summary>
/// <remarks>
/// Two rules, both ported from the ignitionoee TwinCAT driver and both found on a real PLC:
/// <list type="bullet">
/// <item>A value ENDS at its first NUL — <c>0x00</c> for a STRING, an aligned <c>0x00 0x00</c>
/// code unit for a WSTRING. Whatever follows is stale content a longer earlier value left in the
/// buffer (live on the Elma cleaner Z33856: work order <c>67504440</c> read back with a stale
/// <c>"\0" + "1"</c> tail).</item>
/// <item>A STRING is single-byte <b>Windows-1252</b> (umlauts, <c>°</c> in alarm texts), unless
/// the target reports UTF-8 string data (TwinCAT 3.1 4026+ "UTF8 Encoding for STRING").
/// ISO-8859-1 is the fallback on a runtime without code-page support — identical outside
/// 0x80–0x9F.</item>
/// </list>
/// </remarks>
internal static class TwinCATStringCodec
{
private static readonly Encoding Windows1252 = CreateWindows1252();
private static Encoding CreateWindows1252()
{
try
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(1252);
}
catch (Exception)
{
return Encoding.Latin1;
}
}
/// <summary>Decodes an IEC <c>STRING(n)</c> buffer: ends at the first NUL, Windows-1252 unless <paramref name="utf8"/>.</summary>
/// <param name="bytes">The raw symbol bytes (n+1 for a <c>STRING(n)</c>).</param>
/// <param name="utf8">True when the target reports UTF-8 string data.</param>
/// <returns>The decoded value.</returns>
public static string DecodeString(ReadOnlySpan<byte> bytes, bool utf8)
{
var end = bytes.IndexOf((byte)0);
if (end >= 0) bytes = bytes[..end];
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
}
/// <summary>Decodes an IEC <c>WSTRING(n)</c> buffer (UTF-16LE): ends at the first aligned <c>U+0000</c>.</summary>
/// <param name="bytes">The raw symbol bytes (2(n+1) for a <c>WSTRING(n)</c>).</param>
/// <returns>The decoded value.</returns>
public static string DecodeWString(ReadOnlySpan<byte> bytes)
{
var end = bytes.Length & ~1; // whole UTF-16 code units only — a trailing odd byte is ignored
for (var i = 0; i + 1 < end; i += 2)
{
if (bytes[i] == 0 && bytes[i + 1] == 0)
{
end = i;
break;
}
}
return Encoding.Unicode.GetString(bytes[..end]);
}
}
@@ -0,0 +1,149 @@
using System.Text.RegularExpressions;
using TwinCAT.TypeSystem;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>What kind of value a PLC symbol holds, as far as the type guard is concerned.</summary>
internal enum TwinCATPlcKind
{
/// <summary>A primitive / enum / alias-of-primitive — a single fixed-width value.</summary>
Scalar,
/// <summary>An IEC <c>STRING(n)</c> (any n, incl. an alias such as <c>T_MaxString</c>).</summary>
String,
/// <summary>An IEC <c>WSTRING(n)</c>.</summary>
WString,
/// <summary>A struct / FB / array / pointer / reference / <c>VAR_IN_OUT</c> — no scalar value.</summary>
NonScalar,
}
/// <summary>The facts about a PLC symbol the type guard compares a declared type against.</summary>
/// <param name="Kind">The symbol's kind.</param>
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName)
{
private static readonly Regex StringTypeName =
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>
/// Classifies a PLC symbol from its (alias-resolved) type name, its Beckhoff
/// <see cref="DataTypeCategory"/> when the type system resolved one, and its byte size.
/// </summary>
/// <param name="typeName">The PLC type name (<c>STRING(80)</c>, <c>INT16</c>, <c>ST_LOGDATA</c>, …).</param>
/// <param name="category">The resolved type category, or null when the type system has none.</param>
/// <param name="byteSize">The symbol's byte size.</param>
/// <returns>The classified shape.</returns>
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);
}
}
/// <summary>
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A
/// refused pair leaves the node Bad (<see cref="TwinCATStatusMapper.BadTypeMismatch"/>) instead of
/// publishing the PLC's bytes decoded as the wrong type at Good quality. A <c>null</c> result means
/// compatible; otherwise it is a one-line reason for the log.
/// </summary>
/// <remarks>
/// The two paths need different rules because they decode differently:
/// <list type="bullet">
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which
/// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item>
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
/// reinterprets the bit pattern — both at Good.</item>
/// </list>
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
/// (<c>DINT</c> vs <c>UDINT</c>, <c>TIME</c> vs a 4-byte integer) is tolerated so a valid config is
/// never refused.
/// </remarks>
internal static class TwinCATTypeGuard
{
/// <summary>Checks a declared type against a PLC symbol for a symbol-aware READ.</summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
public static string? CheckRead(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
{
var declaredKind = StringKindOf(declared);
if (declaredKind is not null)
{
return plc.Kind == declaredKind
? null
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
}
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
: null;
}
/// <summary>Checks a declared type against a PLC symbol for a raw-bytes NOTIFICATION.</summary>
/// <param name="declared">The tag's declared type.</param>
/// <param name="plc">The PLC symbol's shape.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
public static string? CheckNotification(TwinCATDataType declared, TwinCATPlcSymbolShape plc)
{
var reason = CheckRead(declared, plc);
if (reason is not null || StringKindOf(declared) is not null) return reason;
if (plc.Kind == TwinCATPlcKind.NonScalar)
return $"declared scalar {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — not a scalar";
var size = DeclaredByteSize(declared);
if (size != plc.ByteSize)
return $"declared {declared} ({size} B) but the PLC symbol {plc.TypeName} is {plc.ByteSize} B";
var declaredFloat = declared is TwinCATDataType.Real or TwinCATDataType.LReal;
return declaredFloat == plc.IsFloat
? null
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
}
/// <summary>The byte width the declared type decodes from a notification buffer.</summary>
/// <param name="type">The declared type.</param>
/// <returns>The width in bytes.</returns>
internal static int DeclaredByteSize(TwinCATDataType type) => type switch
{
TwinCATDataType.Bool or TwinCATDataType.SInt or TwinCATDataType.USInt => 1,
TwinCATDataType.Int or TwinCATDataType.UInt => 2,
TwinCATDataType.LInt or TwinCATDataType.ULInt or TwinCATDataType.LReal => 8,
_ => 4, // DInt / UDInt / Real / Time / Date / DateTime / TimeOfDay
};
private static TwinCATPlcKind? StringKindOf(TwinCATDataType type) => type switch
{
TwinCATDataType.String => TwinCATPlcKind.String,
TwinCATDataType.WString => TwinCATPlcKind.WString,
_ => null,
};
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
/// </summary>
internal sealed class TwinCATTypeMismatchException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The guard's one-line reason.</param>
public TwinCATTypeMismatchException(string symbolPath, string reason)
: base($"TwinCAT symbol '{symbolPath}': {reason}")
{
SymbolPath = symbolPath;
}
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
}
@@ -116,6 +116,9 @@ internal class FakeTwinCATClient : ITwinCATClient
public List<FakeNotification> Notifications { get; } = new();
/// <summary>Gets or sets a value indicating whether AddNotificationAsync should throw.</summary>
public bool ThrowOnAddNotification { get; set; }
/// <summary>Per-symbol exceptions <see cref="AddNotificationAsync"/> throws (e.g. a
/// <see cref="TwinCATTypeMismatchException"/> for one refused tag).</summary>
public Dictionary<string, Exception> AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>When set, <see cref="AddNotificationAsync"/> awaits this gate before creating the handle —
@@ -137,6 +140,8 @@ internal class FakeTwinCATClient : ITwinCATClient
{
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
throw perSymbol;
if (AddNotificationGate is { } gate)
await gate.Task.ConfigureAwait(false);
@@ -128,6 +128,51 @@ public sealed class TwinCATNativeNotificationTests
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
}
/// <summary>
/// A tag whose declared type cannot decode its PLC symbol (ignitionoee TwinCAT-002/-008) is refused
/// by the client with <see cref="TwinCATTypeMismatchException"/>. The refusal is ISOLATED: that
/// one tag goes Bad with <c>BadTypeMismatch</c> and every other tag in the same subscribe call
/// still registers — before, the exception tore the whole subscription down.
/// </summary>
[Fact]
public async Task Native_subscribe_isolates_a_type_mismatched_tag_to_BadTypeMismatch()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Order"] =
new TwinCATTypeMismatchException("MAIN.Order", "declared DInt but the PLC symbol is the string STRING(3)");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Count", "ads://5.23.91.23.1.1:851", "MAIN.Count", TwinCATDataType.DInt),
new TwinCATTagDefinition("Order", "ads://5.23.91.23.1.1:851", "MAIN.Order", TwinCATDataType.DInt),
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.Real),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Count", "Order", "Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.Count", "MAIN.Speed"], ignoreOrder: true);
var refused = events.ShouldHaveSingleItem();
refused.FullReference.ShouldBe("Order");
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
refused.Snapshot.Value.ShouldBeNull();
// The good tags still deliver, and unsubscribe tears down cleanly.
fake.FireNotification("MAIN.Count", 7);
events.Last().Snapshot.Value.ShouldBe(7);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
fake.Notifications.ShouldBeEmpty();
}
private sealed class AbTagParamsIrrelevant { }
private sealed class FailAfterNAddsFake : FakeTwinCATClient
@@ -0,0 +1,82 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <see cref="TwinCATStringCodec"/> — how an IEC <c>STRING(n)</c> / <c>WSTRING(n)</c> buffer,
/// read at the PLC's own declared width, becomes a value. Ported from the ignitionoee TwinCAT
/// driver (0.19.3 first-NUL STRING, 0.19.4 TwinCAT-007 first-NUL WSTRING and TwinCAT-010
/// Windows-1252), whose byte fixtures these reuse.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATStringCodecTests
{
/// <summary>
/// An IEC STRING ends at its FIRST NUL; the buffer beyond keeps whatever a longer earlier value
/// left there. Live on the Elma cleaner Z33856 (2026-09-24) a decoder that only trimmed trailing
/// NULs published work order <c>67504440</c> as <c>"67504440\0" + "1"</c> and part
/// <c>110032285</c> as <c>"110032285\0" + "1\0" + "30"</c> — a wrong value at Good quality.
/// </summary>
[Fact]
public void String_stops_at_the_first_nul_and_ignores_stale_bytes_after_it()
{
byte[] workOrder = [(byte)'6', (byte)'7', (byte)'5', (byte)'0', (byte)'4', (byte)'4', (byte)'4', (byte)'0',
0x00, (byte)'1', 0x00, (byte)'3', (byte)'0', 0x00];
TwinCATStringCodec.DecodeString(workOrder, utf8: false).ShouldBe("67504440");
byte[] emptied = [0x00, (byte)'O', (byte)'L', (byte)'D'];
TwinCATStringCodec.DecodeString(emptied, utf8: false).ShouldBe("");
}
/// <summary>A buffer with no NUL at all (a full-length value) decodes whole.</summary>
[Fact]
public void String_without_a_nul_decodes_the_whole_buffer()
=> TwinCATStringCodec.DecodeString("ABC"u8.ToArray(), utf8: false).ShouldBe("ABC");
/// <summary>
/// An IEC STRING on TwinCAT is single-byte Windows-1252, not ASCII and not the host's culture
/// code page: <c>°</c> (0xB0, an Elma alarm text), <c>ä</c> (0xE4) and <c>€</c> (0x80, the one
/// byte where 1252 and ISO-8859-1 differ) must decode to themselves on any host.
/// </summary>
[Fact]
public void String_decodes_windows_1252_high_bytes()
{
byte[] text = [(byte)'8', (byte)'0', 0xB0, (byte)'C', (byte)' ', 0xE4, 0x80, 0x00, 0xFF];
TwinCATStringCodec.DecodeString(text, utf8: false).ShouldBe("80°C ä€");
}
/// <summary>
/// A TwinCAT 3.1 build 4026+ project with "UTF8 Encoding for STRING" on reports UTF-8 string
/// data; the codec honours it (still ending at the first NUL).
/// </summary>
[Fact]
public void String_decodes_utf8_when_the_target_reports_utf8_strings()
{
var bytes = Encoding.UTF8.GetBytes("80°C ä€").Concat(new byte[] { 0x00, (byte)'X' }).ToArray();
TwinCATStringCodec.DecodeString(bytes, utf8: true).ShouldBe("80°C ä€");
}
/// <summary>
/// A WSTRING ends at its first ALIGNED <c>U+0000</c> code unit (TwinCAT-007): <c>"NEW\0LD"</c>
/// must publish <c>"NEW"</c>. A zero HIGH byte of a non-NUL unit (every ASCII character in
/// UTF-16LE) is not a terminator.
/// </summary>
[Fact]
public void WString_stops_at_the_first_aligned_nul_code_unit()
{
var bytes = Encoding.Unicode.GetBytes("NEW\0LD\0");
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("NEW");
TwinCATStringCodec.DecodeWString(Encoding.Unicode.GetBytes("Grüße")).ShouldBe("Grüße");
}
/// <summary>A trailing odd byte is not half a character — it is ignored.</summary>
[Fact]
public void WString_ignores_a_trailing_odd_byte()
{
var bytes = Encoding.Unicode.GetBytes("AB").Concat(new byte[] { 0x43 }).ToArray();
TwinCATStringCodec.DecodeWString(bytes).ShouldBe("AB");
}
}
@@ -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;
/// <summary>
/// Pins <see cref="TwinCATTypeGuard"/> — whether a tag's DECLARED <see cref="TwinCATDataType"/> may
/// decode the PLC symbol it names. Ported from the ignitionoee TwinCAT driver's extra-type
/// reconciliation (TwinCAT-002 width / float kind, TwinCAT-008 string kind): a refused pair leaves
/// the node Bad (<c>BadTypeMismatch</c>) instead of publishing the PLC's bytes decoded as the wrong
/// type at Good quality.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATTypeGuardTests
{
private static TwinCATPlcSymbolShape Plc(string typeName, int byteSize, DataTypeCategory? category = null)
=> TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
// ---- classification ----
/// <summary>The PLC's type name / category classifies into the four kinds the guard reasons about.</summary>
[Fact]
public void Classify_recognises_string_kinds_scalars_and_containers()
{
Plc("STRING(80)", 81).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("STRING", 81).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("WSTRING(20)", 42).Kind.ShouldBe(TwinCATPlcKind.WString);
Plc("T_MaxString", 256, DataTypeCategory.String).Kind.ShouldBe(TwinCATPlcKind.String);
Plc("STRING_DATA", 40).Kind.ShouldBe(TwinCATPlcKind.Scalar); // a UDT named STRING_… is not a string
Plc("ST_LOGDATA", 986, DataTypeCategory.Struct).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("ARRAY [0..12] OF DINT", 52).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("INT (VAR_IN_OUT)", 4).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer).Kind.ShouldBe(TwinCATPlcKind.NonScalar);
Plc("REAL", 4).IsFloat.ShouldBeTrue();
Plc("INT16", 2).IsFloat.ShouldBeFalse();
}
// ---- read path (symbol-aware: only the string kind can go wrong) ----
/// <summary>
/// TwinCAT-008: before the fix only width separated a string from a number, so a numeric declared
/// over a PLC string decoded the string's bytes as a number at Good. The Beckhoff read path hands
/// a declared-numeric tag over a PLC <c>STRING(3)</c> back a <c>string</c> object at Good — refused.
/// </summary>
/// <param name="declared">The tag's declared type.</param>
[Theory]
[InlineData(TwinCATDataType.DInt)]
[InlineData(TwinCATDataType.UDInt)]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.Int)]
[InlineData(TwinCATDataType.LInt)]
public void Read_refuses_a_numeric_declared_over_a_plc_string(TwinCATDataType declared)
=> TwinCATTypeGuard.CheckRead(declared, Plc("STRING(3)", 4)).ShouldNotBeNull();
/// <summary>A string declared over a number, the other string kind, or a container is refused.</summary>
[Fact]
public void Read_refuses_a_string_over_anything_but_the_same_string_kind()
{
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("DINT", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("STRING(161)", 162)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("WSTRING(80)", 162)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
}
/// <summary>
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
/// </summary>
[Fact]
public void Read_accepts_same_kind_strings_of_any_length_and_numeric_pairs()
{
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(8)", 9)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("STRING(255)", 256)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("E_STATE", 2, DataTypeCategory.Enum)).ShouldBeNull();
}
// ---- notification path (raw bytes decoded as the declared type) ----
/// <summary>
/// TwinCAT-002: an ADS notification carries raw bytes decoded as the DECLARED type, so a same-width
/// float/integer clash reinterprets the bit pattern (DINT over REAL) and a width mismatch truncates
/// (DINT over LINT) — both at Good before the guard.
/// </summary>
[Fact]
public void Notification_refuses_width_and_float_kind_mismatches()
{
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("REAL", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("DINT", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("LINT", 8)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.LReal, Plc("REAL", 4)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("ARRAY [0..12] OF DINT", 52)).ShouldNotBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.DInt, Plc("STRING(3)", 4)).ShouldNotBeNull();
}
/// <summary>
/// A same-width, same-kind divergence (DINT vs UDINT, TIME vs a 4-byte integer, a TC2 INT16) is
/// tolerated so a valid config is never refused; strings of the same kind match at any length.
/// </summary>
[Fact]
public void Notification_tolerates_same_width_same_kind_and_same_kind_strings()
{
TwinCATTypeGuard.CheckNotification(TwinCATDataType.UDInt, Plc("DINT", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Time, Plc("UDINT", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Int, Plc("INT16", 2)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Bool, Plc("BOOL", 1)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.Real, Plc("REAL", 4)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.String, Plc("STRING(14)", 15)).ShouldBeNull();
TwinCATTypeGuard.CheckNotification(TwinCATDataType.WString, Plc("WSTRING(20)", 42)).ShouldBeNull();
}
}