fix(ablegacy): a status the PLC returned is an answer, never a comms fault
Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC STS, e.g. an element past the end of the file; or a CIP error) arrives as ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a no-reply comms fault. A coalesced block with one such member then failed as a group every poll, and two refused tags in a row opened the device's fail-fast window against a device that was answering. - LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's). - IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status except Unsupported (BadReply / Partial included: a reply arrived). - A coalesced block refused with ANY returned status falls back to per-member reads in the same pass; the shape is remembered only after a repeat, and the refused block's handle is always evicted (M5). - A runtime status Unsupported is neutral (keeps the handle); the probe no longer counts it as a fault. - Refusal warnings log the status actually answered (M8). Live: the new past-end-of-file ab_server case fails on libplctag with the old translation and passes with it, on slc500/micrologix/plc5 x both transports. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
+40
@@ -66,4 +66,44 @@ public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture
|
||||
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
|
||||
/// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
|
||||
/// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
|
||||
/// </summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
|
||||
AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
|
||||
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
|
||||
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError,
|
||||
$"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using libplctag;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I1: on the default libplctag transport a PLC's own refusal (a non-zero PCCC STS — e.g. an element
|
||||
/// past the end of the file) arrives as <c>ERR_REMOTE_ERR</c>, which used to translate to <c>BadConnection</c>: a
|
||||
/// no-reply comms fault. A coalesced block holding one such member then failed as a group every poll, and two
|
||||
/// refused tags in a row tripped the device's fail-fast window. Statuses the device RETURNED are now answers;
|
||||
/// only no-reply statuses are comms faults.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAnsweredStatusTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>What the libplctag runtime's GetStatus() reports for a PLC refusal (through the real translation).</summary>
|
||||
private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
|
||||
|
||||
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = coalesce,
|
||||
}, "drv-i1", factory);
|
||||
|
||||
[Fact]
|
||||
public void Remote_error_translates_to_an_answered_status_not_a_comms_fault()
|
||||
{
|
||||
var s = LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
|
||||
AbLegacyDriver.IsNoReply(s).ShouldBeFalse();
|
||||
AbLegacyDriver.IsDeviceRefusal(s).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadStatus)).ShouldBeTrue();
|
||||
// Positive control: real no-reply statuses still are.
|
||||
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
|
||||
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadConnection)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.BadReply)] // native: an STS the transport does not map
|
||||
[InlineData(AbStatus.Partial)]
|
||||
[InlineData(AbStatus.NotFound)]
|
||||
[InlineData(AbStatus.DeviceStateConflict)]
|
||||
public void Statuses_the_device_returned_are_answers(AbStatus status) =>
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.Timeout)]
|
||||
[InlineData(AbStatus.BadConnection)]
|
||||
[InlineData(AbStatus.Abort)]
|
||||
[InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
|
||||
public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
|
||||
{
|
||||
// N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
|
||||
? new FakeAbLegacyTag(p) { Status = RemoteErr }
|
||||
: new FakeAbLegacyTag(p) { Value = 48 },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: true,
|
||||
new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 4; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadDeviceFailure, $"poll {poll}");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consecutive_refused_tags_do_not_trip_fail_fast_on_an_answering_device()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: false,
|
||||
new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
|
||||
r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: an answering device is never failed fast");
|
||||
}
|
||||
factory.Tags["N7:9"].ReadCount.ShouldBe(3);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // refused handles are kept, not churned
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,9 @@ public sealed class AbLegacyHostAndStatusTests
|
||||
Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported,
|
||||
// DELIBERATE change (review 07 r0 I1): a PLC's own refusal (PCCC STS / CIP status → ERR_REMOTE_ERR /
|
||||
// ERR_BAD_STATUS) is "device answered", not a comms error.
|
||||
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbLegacyStatusMapper.BadDeviceFailure,
|
||||
_ => AbLegacyStatusMapper.BadCommunicationError,
|
||||
};
|
||||
|
||||
|
||||
+4
-2
@@ -113,7 +113,7 @@ public sealed class AbLegacyReadCoalescingTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried()
|
||||
public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat()
|
||||
{
|
||||
// N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
|
||||
var (drv, _, created) = NewDriver(
|
||||
@@ -133,7 +133,9 @@ public sealed class AbLegacyReadCoalescingTests
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
|
||||
}
|
||||
created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered
|
||||
// Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat,
|
||||
// so poll 2 does not try the block again.
|
||||
created.Count(c => c.ElementCount > 1).ShouldBe(2);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user