diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 5c780907..36907a15 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1,3 +1,4 @@ +using libplctag; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.Observability; @@ -818,10 +819,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } catch (Exception ex) { - // Transport exception — evict so the next read creates a fresh handle. + // Transport exception — evict so the next read creates a fresh handle. A libplctag + // exception carries its Status, so a tag the controller does not have reads + // BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error. EvictRuntime(device, def.Name); results[fb.OriginalIndex] = new DataValueSnapshot(null, - AbCipStatusMapper.BadCommunicationError, null, now); + AbCipStatusMapper.MapException(ex), null, now); _logger.LogWarning(ex, "AbCip read transport exception for tag {Tag} on device {Device}", reference, def.DeviceHostAddress); @@ -891,7 +894,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, catch (Exception ex) { EvictRuntime(device, parent.Name); - StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError); + StampGroupStatus(group, results, now, AbCipStatusMapper.MapException(ex)); _logger.LogWarning(ex, "AbCip whole-UDT read transport exception for parent {Parent} on device {Device}", group.ParentName, parent.DeviceHostAddress); @@ -1158,10 +1161,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { await runtime.InitializeAsync(ct).ConfigureAwait(false); } - catch + catch (Exception ex) { runtime.Dispose(); - device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window + // 05/STAB-8: a failed create opens the backoff window — but only when the device did + // not answer. "Tag not found" means the controller replied over a working session; it + // is a config error on this one tag, and counting it as a failed Forward-Open throttled + // every sibling tag on the device (seen live: one typo blacked out the whole device). + if (ex is LibPlcTagException lpe + && AbCipStatusMapper.TryGetStatus(lpe, out var status) + && status != Status.Ok + && AbCipDriverProbe.IsReachableStatus(status)) + device.Backoff.RecordSuccess(); + else + device.Backoff.RecordFailure(DateTime.UtcNow); throw; } device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index 9ad6abae..ccfa04f4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -200,7 +200,7 @@ public sealed class AbCipDriverProbe : IDriverProbe internal static bool IsReachableException(LibPlcTagException ex) { var msg = ex.Message ?? string.Empty; - if (Enum.TryParse(msg.Trim(), ignoreCase: true, out var status) && Enum.IsDefined(status)) + if (AbCipStatusMapper.TryGetStatus(ex, out var status)) return IsReachableStatus(status); return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs index 4f50252b..bbe48a20 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs @@ -76,6 +76,34 @@ public static class AbCipStatusMapper /// The mapped OPC UA StatusCode value. public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status); + /// + /// Recover the libplctag a was thrown on. + /// The wrapper builds the exception from Status.ToString(), so the message is the enum + /// name ("ErrorNotFound"). + /// + /// The exception. + /// The recovered status. + /// true when the message names a defined . + public static bool TryGetStatus(LibPlcTagException ex, out Status status) + { + ArgumentNullException.ThrowIfNull(ex); + return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status) + && Enum.IsDefined(status); + } + + /// + /// Status for an exception thrown while creating / reading a tag: a libplctag exception maps + /// through its (so a tag the controller does not have reads + /// , not a comms error); anything else is + /// . + /// + /// The exception. + /// The OPC UA status code. + public static uint MapException(Exception ex) => + ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status) + ? MapLibplctagStatus(status) + : BadCommunicationError; + /// /// Map a libplctag.NET enum value to an OPC UA StatusCode. This is /// the strongly-typed core of the mapper; the int overload exists only for the diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs new file mode 100644 index 00000000..15643010 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs @@ -0,0 +1,73 @@ +using libplctag; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// A tag the controller does not have is a configuration error, not a lost connection. Seen live +/// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's +/// create threw LibPlcTagException("ErrorNotFound"), the driver counted that as a failed +/// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag +/// on the device — all of them real — came back BadCommunicationError (throttled). The bad +/// tag itself also read BadCommunicationError instead of BadNodeIdUnknown. +/// +[Trait("Category", "Unit")] +public sealed class AbCipUnknownTagReadTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(Func initFault) + { + var factory = new FakeAbCipTagFactory + { + Customise = p => initFault(p.TagName) is { } ex + ? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = ex } + : new FakeAbCipTag(p) { Value = 42 }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("Typo", Device, "NoSuchTag_xyz", AbCipDataType.DInt), + new AbCipTagDefinition("Real1", Device, "WW_InCycle", AbCipDataType.DInt), + new AbCipTagDefinition("Real2", Device, "WW_ActualPress", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + MaxConcurrentReads = 1, + }, "abcip-1", factory); + return (drv, factory); + } + + /// The unknown tag reads BadNodeIdUnknown and its siblings still connect and read Good. + [Fact] + public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown() + { + var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + r[1].Value.ShouldBe(42); + r[2].StatusCode.ShouldBe(AbCipStatusMapper.Good); + } + + /// + /// Falsification control: a genuine connection failure still opens the backoff window, so a + /// sibling cold tag is throttled exactly as before (05/STAB-8 is unchanged). + /// + [Fact] + public async Task A_connection_failure_still_opens_the_backoff_window() + { + var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); + factory.Tags.ContainsKey("WW_InCycle").ShouldBeFalse(); // throttled before any create + } +}