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 6de5e0ac..bc10d2c4 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -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;
- }
-
///
public async Task 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
}
///
- /// Runs on the AMS router thread. Does the cheap bit-extraction
- /// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
+ /// Runs on the 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.
///
@@ -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); }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
new file mode 100644
index 00000000..186e1cee
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
@@ -0,0 +1,41 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+///
+/// Serves a bit-indexed BOOL tag (MAIN.Flags.3, .ADSSTATE_RUN.0) from its PARENT word.
+/// ADS has no symbol for WORD.N — live on a TwinCAT 2.11 target a notification registered on
+/// .ADSSTATE_RUN.0 failed with DeviceSymbolNotFound — 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.
+///
+internal static class TwinCATBitAccess
+{
+ /// The parent word's path: the bit path without its trailing .N.
+ /// The bit path (MAIN.Flags.3).
+ /// The parent path, or when it has no numeric last segment.
+ 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;
+ }
+
+ /// Checks that addresses a bit of an integer parent word.
+ /// The bit index.
+ /// The parent symbol's shape.
+ /// null when addressable; otherwise the reason.
+ 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}";
+ }
+
+ /// Reads bit of a little-endian parent word.
+ /// The parent word's raw bytes.
+ /// The bit index (0 = least significant bit of the first byte).
+ /// The bit's value.
+ public static bool ReadBit(ReadOnlySpan parent, int bit) =>
+ (parent[bit / 8] & (1 << (bit % 8))) != 0;
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
new file mode 100644
index 00000000..3dc22507
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
@@ -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;
+
+///
+/// Pins — how a bit-indexed BOOL tag (.ADSSTATE_RUN.0) is served by a
+/// native notification. ADS has no symbol for WORD.N: live on the TwinCAT 2.11 Elma cleaner Z33856
+/// (2026-10-07) registering the notification on .ADSSTATE_RUN.0 failed with
+/// DeviceSymbolNotFound — 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.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATBitAccessTests
+{
+ /// The parent path is the bit path without its trailing .N; a non-bit path is unchanged.
+ [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");
+ }
+
+ /// Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1).
+ [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();
+ }
+
+ /// A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good.
+ [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();
+ }
+
+ /// Only an integer word has addressable bits — a string, a float or a container is refused.
+ [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();
+ }
+}