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:
@@ -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
|
||||
|
||||
@@ -200,7 +200,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
internal static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
if (Enum.TryParse<Status>(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)
|
||||
|
||||
@@ -76,6 +76,34 @@ public static class AbCipStatusMapper
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
|
||||
/// <summary>
|
||||
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
|
||||
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
|
||||
/// name (<c>"ErrorNotFound"</c>).
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <param name="status">The recovered status.</param>
|
||||
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
|
||||
/// through its <see cref="Status"/> (so a tag the controller does not have reads
|
||||
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
|
||||
/// <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
|
||||
? MapLibplctagStatus(status)
|
||||
: BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
|
||||
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user