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