e3cb4f0c7b
Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero PCCC STS and CIP status into that code, including a routing hop's "unconnected send timed out" / "connection lost" / "remote node missing", and native BadReply covers truncated / mismatched / unmapped replies - so a powered-off PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing, the comms-loss watchdog unable to fire. Three classes now, each pinned both ways: - no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault; - answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/ TooSmall/TypeMismatch/DeviceStateConflict): proves reachability; - OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad, handle kept, coalescing fallback, never fail-fast, never an answer. Option (a): a device whose pass/call produced only opaque outcomes is Degraded with LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC. The probe and index reads classify the same way. Minors: - N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of decoding the file at the wrong width as Good. - N3: a swept indirect resolution is never reconnected under a forgotten gate; that one call answers neutrally. - N4: an index read answered Unsupported is neutral, not a transport fault. - N5: ArrayFileMismatch checks indirect addresses (the file letter is static). - Test nit: the evicted-while-waiting test signals gate arrival via a seam instead of sleeping. Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
147 lines
7.7 KiB
C#
147 lines
7.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
|
|
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
|
|
/// otherwise compares whatever the simulator holds.
|
|
/// </summary>
|
|
[Collection(AbLegacyServerCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Simulator", "ab_server-PCCC")]
|
|
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
|
|
{
|
|
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</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 Coalesced_reads_match_single_reads(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}";
|
|
AbLegacyTagDefinition[] tags =
|
|
[
|
|
new("W0", uri, "N7:0", AbLegacyDataType.Int),
|
|
new("W1", uri, "N7:1", AbLegacyDataType.Int),
|
|
new("W4", uri, "N7:4", AbLegacyDataType.Int),
|
|
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
|
|
];
|
|
string[] refs = [.. tags.Select(t => t.Name)];
|
|
|
|
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
|
RawTags = AbLegacyRawTags.Entries(tags),
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
Transport = transport,
|
|
CoalesceReads = coalesce,
|
|
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
|
|
|
|
await using var single = Make(coalesce: false);
|
|
await using var coalesced = Make(coalesce: true);
|
|
await single.InitializeAsync("{}", ct);
|
|
await coalesced.InitializeAsync("{}", ct);
|
|
|
|
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
|
|
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
|
|
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
|
|
&& !writesRefused)
|
|
{
|
|
(await single.WriteAsync(
|
|
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
|
|
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
|
}
|
|
|
|
var a = await single.ReadAsync(refs, ct);
|
|
var b = await coalesced.ReadAsync(refs, ct);
|
|
|
|
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
|
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");
|
|
}
|
|
// The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the
|
|
// answering neighbours keep the driver Healthy and the device is never failed fast.
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR —
|
|
/// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that
|
|
/// reply must not look reachable: Degraded, <c>LastSuccessfulRead</c> frozen (the comms-loss watchdog's
|
|
/// evidence). The native transport maps the STS itself and is only checked to stay non-Good.
|
|
/// </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 An_opaque_only_device_never_proves_reachability(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("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
Transport = transport,
|
|
}, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}");
|
|
await drv.InitializeAsync("{}", ct);
|
|
var initLastRead = drv.GetHealth().LastSuccessfulRead;
|
|
await Task.Delay(20, ct);
|
|
|
|
for (var poll = 0; poll < 2; poll++)
|
|
(await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
|
|
|
if (transport == AbLegacyTransport.Libplctag)
|
|
{
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead);
|
|
}
|
|
}
|
|
}
|