fix(twincat): register a bit-indexed BOOL notification on its parent word (live TC2 gate)
A native notification for MAIN.Flags.3 / .ADSSTATE_RUN.0 was registered on the literal WORD.N path, which ADS has no symbol for: live on the TC 2.11 Elma cleaner Z33856 it failed with DeviceSymbolNotFound, and that registration error tore down the whole subscribe call. The read path already reads the parent word; the notification now does the same - it registers the parent as raw bytes at its resolved width and reads the bit (TwinCATBitAccess), refusing a bit outside the parent or a non-integer parent with BadTypeMismatch.
This commit is contained in:
@@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// is inherently scalar — arrayCount does not apply.
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
var parent = StripBitSuffix(symbolPath);
|
||||
var parent = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||
@@ -257,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
? TwinCATStringCodec.DecodeWString(bytes)
|
||||
: TwinCATStringCodec.DecodeString(bytes, utf8);
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
|
||||
? symbolPath[..lastDot]
|
||||
: symbolPath;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
@@ -318,7 +309,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
int[]? anyTypeArgs = null;
|
||||
var stringsUtf8 = false;
|
||||
TwinCATArrayLayout? arrayLayout = null;
|
||||
if (bitIndex is null || type != TwinCATDataType.Bool)
|
||||
var registerPath = symbolPath;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
|
||||
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
|
||||
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
|
||||
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
|
||||
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [parent.ByteSize];
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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
|
||||
@@ -367,11 +375,11 @@ 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, anyTypeArgs, cancellationToken)
|
||||
registerPath, 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}");
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||||
{
|
||||
@@ -383,8 +391,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
|
||||
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
|
||||
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// can never stall ADS notification delivery for the rest of the process.
|
||||
/// Drops the notification (DropWrite) if the queue is saturated.
|
||||
/// </summary>
|
||||
@@ -392,8 +400,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool)
|
||||
{
|
||||
if (value is not byte[] word || bit / 8 >= word.Length) return;
|
||||
value = TwinCATBitAccess.ReadBit(word, bit);
|
||||
}
|
||||
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
|
||||
{
|
||||
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Serves a bit-indexed BOOL tag (<c>MAIN.Flags.3</c>, <c>.ADSSTATE_RUN.0</c>) from its PARENT word.
|
||||
/// ADS has no symbol for <c>WORD.N</c> — live on a TwinCAT 2.11 target a notification registered on
|
||||
/// <c>.ADSSTATE_RUN.0</c> failed with <c>DeviceSymbolNotFound</c> — so a native notification registers
|
||||
/// the parent as raw bytes at its resolved width and reads the bit here, exactly as a read already
|
||||
/// reads the parent word and extracts the bit.
|
||||
/// </summary>
|
||||
internal static class TwinCATBitAccess
|
||||
{
|
||||
/// <summary>The parent word's path: the bit path without its trailing <c>.N</c>.</summary>
|
||||
/// <param name="symbolPath">The bit path (<c>MAIN.Flags.3</c>).</param>
|
||||
/// <returns>The parent path, or <paramref name="symbolPath"/> when it has no numeric last segment.</returns>
|
||||
public static string ParentPath(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) ? symbolPath[..lastDot] : symbolPath;
|
||||
}
|
||||
|
||||
/// <summary>Checks that <paramref name="bit"/> addresses a bit of an integer parent word.</summary>
|
||||
/// <param name="bit">The bit index.</param>
|
||||
/// <param name="parent">The parent symbol's shape.</param>
|
||||
/// <returns><c>null</c> when addressable; otherwise the reason.</returns>
|
||||
public static string? CheckParent(int bit, TwinCATPlcSymbolShape parent)
|
||||
{
|
||||
if (parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat)
|
||||
return $"bit {bit} of {parent.TypeName} ({parent.ByteSize} B) — only an integer word has addressable bits";
|
||||
return bit >= 0 && bit < parent.ByteSize * 8
|
||||
? null
|
||||
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
|
||||
}
|
||||
|
||||
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
|
||||
/// <param name="parent">The parent word's raw bytes.</param>
|
||||
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
|
||||
/// <returns>The bit's value.</returns>
|
||||
public static bool ReadBit(ReadOnlySpan<byte> parent, int bit) =>
|
||||
(parent[bit / 8] & (1 << (bit % 8))) != 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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="TwinCATBitAccess"/> — how a bit-indexed BOOL tag (<c>.ADSSTATE_RUN.0</c>) is served by a
|
||||
/// native notification. ADS has no symbol for <c>WORD.N</c>: live on the TwinCAT 2.11 Elma cleaner Z33856
|
||||
/// (2026-10-07) registering the notification on <c>.ADSSTATE_RUN.0</c> failed with
|
||||
/// <c>DeviceSymbolNotFound</c> — and that registration error tore down the WHOLE subscribe call. The
|
||||
/// notification now registers the PARENT word as raw bytes (its resolved width) and extracts the bit.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATBitAccessTests
|
||||
{
|
||||
/// <summary>The parent path is the bit path without its trailing <c>.N</c>; a non-bit path is unchanged.</summary>
|
||||
[Fact]
|
||||
public void ParentPath_strips_the_trailing_bit_index()
|
||||
{
|
||||
TwinCATBitAccess.ParentPath(".ADSSTATE_RUN.0").ShouldBe(".ADSSTATE_RUN");
|
||||
TwinCATBitAccess.ParentPath("MAIN.Status.Flags.15").ShouldBe("MAIN.Status.Flags");
|
||||
TwinCATBitAccess.ParentPath("MAIN.Flags").ShouldBe("MAIN.Flags");
|
||||
}
|
||||
|
||||
/// <summary>Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1).</summary>
|
||||
[Fact]
|
||||
public void ReadBit_numbers_bits_little_endian_across_the_parent_bytes()
|
||||
{
|
||||
byte[] word = [0b0000_0101, 0b0000_0010]; // .ADSSTATE_RUN = 5 → bits 0 and 2; plus bit 9
|
||||
TwinCATBitAccess.ReadBit(word, 0).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 1).ShouldBeFalse();
|
||||
TwinCATBitAccess.ReadBit(word, 2).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 9).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 15).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good.</summary>
|
||||
[Fact]
|
||||
public void CheckParent_refuses_a_bit_outside_the_parent_width()
|
||||
{
|
||||
var word = TwinCATPlcSymbolShape.Classify("UINT16", DataTypeCategory.Primitive, 2);
|
||||
TwinCATBitAccess.CheckParent(15, word).ShouldBeNull();
|
||||
TwinCATBitAccess.CheckParent(16, word).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(-1, word).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Only an integer word has addressable bits — a string, a float or a container is refused.</summary>
|
||||
[Fact]
|
||||
public void CheckParent_refuses_a_parent_that_is_not_an_integer_word()
|
||||
{
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, 81)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user