fix(abcip): an unknown tag no longer throttles its whole device or reads as a comms error

Live read-only check against a production CompactLogix press: with one nonexistent tag
first in the read list, libplctag's create threw LibPlcTagException("ErrorNotFound"); the
driver counted it as a failed Forward-Open, opened the device's 05/STAB-8 connect-backoff
window, and every other cold tag on the device - all real (WW_InCycle, WW_ActualPress,
WW_WorkOrderNum) - read BadCommunicationError. In steady polling the bad tag re-opens
the window each attempt, so siblings ordered after it may never connect.

- A create that fails with a "controller answered" status (NotFound/NoMatch/BadDevice,
  the probe's existing reachable classification) records backoff SUCCESS, not failure.
  Genuine connection failures still open the window (falsification control test).
- Read exceptions map through the libplctag Status they carry: unknown tag ->
  BadNodeIdUnknown, decode past the buffer -> BadOutOfRange, timeout -> BadTimeout,
  instead of a blanket BadCommunicationError. One status-name parser
  (AbCipStatusMapper.TryGetStatus) now serves both the read path and the probe.
This commit is contained in:
Joseph Doherty
2026-10-07 14:09:43 -04:00
parent a0a024c80b
commit 0ca56cb1f6
4 changed files with 120 additions and 6 deletions
@@ -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;
/// <summary>
/// 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 <c>LibPlcTagException("ErrorNotFound")</c>, 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 <c>BadCommunicationError</c> (throttled). The bad
/// tag itself also read <c>BadCommunicationError</c> instead of <c>BadNodeIdUnknown</c>.
/// </summary>
[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<string, Exception?> 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);
}
/// <summary>The unknown tag reads BadNodeIdUnknown and its siblings still connect and read Good.</summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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
}
}