004c09fa13
Review I2: Driver.Ab.Transport raises statuses that came off the wire in an Execute PCCC reply (envelope CIP status, PCCC STS) as AbDeviceReplyException. AbLegacy's IsLocalRefusal now excludes it, and the new IsAnsweredRefusal (used by the read, write, probe and indirect-index paths) counts a wire Unsupported - CIP 0x08/0x1C/0x20 on the envelope, STS 0x10 - as answered: per-tag BadNotSupported, Healthy, LastSuccessfulRead advanced. Before, they landed in the 'refused before the wire' neutral class. Pre-wire refusals are unchanged (still neutral). A socket test covers all four codes (red before); transport tests pin the marker and a pre-wire builder refusal as the positive control. The AbCip Forward Open BadConnection path is untouched. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
132 lines
6.2 KiB
C#
132 lines
6.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION
|
|
/// <see cref="NativeAbLegacyTagFactory"/>: a bridge (DHRIO / 1761-NET-ENI) in front of a dead DH+ node answers every
|
|
/// PCCC request with STS 0x30 "remote node host missing". That is NOT the device answering, so the driver must treat
|
|
/// it as a comms fault — Degraded, <c>LastSuccessfulRead</c> not advanced — or the comms-loss watchdog never fires
|
|
/// for it. The positive control is a live device refusing a tag (STS 0x60, command protection): per-tag Bad, Healthy,
|
|
/// <c>LastSuccessfulRead</c> advanced.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class NativePcccBridgeStatusHealthTests
|
|
{
|
|
private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21];
|
|
|
|
/// <summary>A fake whose Execute PCCC envelope itself is refused with CIP general status <paramref name="general"/>.</summary>
|
|
private static FakeEipServer CipEnvelopeServer(byte general)
|
|
{
|
|
var server = new FakeEipServer { ZeroReplyContext = true };
|
|
server.Handler = req => FakeEipServer.Command(req) == Ab.Transport.Eip.EncapCommand.SendRRData
|
|
? FakeEipServer.RRReply(req, [0xCB, 0x00, general, 0x00])
|
|
: null;
|
|
return server;
|
|
}
|
|
|
|
/// <summary>A PCCC fake that answers every request with one fixed STS.</summary>
|
|
private static FakeEipServer StsServer(byte sts)
|
|
{
|
|
var server = new FakeEipServer { ZeroReplyContext = true };
|
|
server.Handler = req =>
|
|
{
|
|
if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null;
|
|
var cip = FakeEipServer.RequestCip(req);
|
|
byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F];
|
|
return FakeEipServer.RRReply(req, [.. head, sts, cip[15], cip[16]]);
|
|
};
|
|
return server;
|
|
}
|
|
|
|
#pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap)
|
|
private static async Task<AbLegacyDriver> DriverAsync(FakeEipServer server)
|
|
{
|
|
var host = $"ab://127.0.0.1:{server.Port}/1,0";
|
|
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
|
|
RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("T", host, "N7:0", AbLegacyDataType.Int)]),
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
}, "drv-pccc-bridge", new NativeAbLegacyTagFactory(new DeviceSessionPool()));
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
return driver;
|
|
}
|
|
|
|
/// <summary>STS 0x30 from a bridge: a comms fault, never evidence the device answered.</summary>
|
|
[Fact]
|
|
public async Task A_dead_node_behind_a_bridge_degrades_and_does_not_advance_LastSuccessfulRead()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var server = StsServer(0x30);
|
|
var driver = await DriverAsync(server);
|
|
var before = driver.GetHealth().LastSuccessfulRead;
|
|
|
|
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
|
|
|
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
|
var health = driver.GetHealth();
|
|
health.State.ShouldBe(DriverState.Degraded);
|
|
health.LastSuccessfulRead.ShouldBe(before);
|
|
await driver.ShutdownAsync(ct);
|
|
server.HandlerErrors.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced.</summary>
|
|
[Fact]
|
|
public async Task A_live_device_refusing_the_tag_stays_Healthy_and_advances_LastSuccessfulRead()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var server = StsServer(0x60);
|
|
var driver = await DriverAsync(server);
|
|
var before = driver.GetHealth().LastSuccessfulRead;
|
|
|
|
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
|
|
|
result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable);
|
|
var health = driver.GetHealth();
|
|
health.State.ShouldBe(DriverState.Healthy);
|
|
health.LastSuccessfulRead.ShouldNotBeNull();
|
|
health.LastSuccessfulRead.ShouldNotBe(before);
|
|
await driver.ShutdownAsync(ct);
|
|
server.HandlerErrors.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review I2: a refusal that came OFF THE WIRE — the device (or the bridge's Execute PCCC object) answered with
|
|
/// CIP 0x08 / 0x1C / 0x20 on the envelope, or PCCC STS 0x10 — is an answered refusal: per-tag BadNotSupported,
|
|
/// Healthy, <c>LastSuccessfulRead</c> advanced. It must not land in the "refused before the wire" neutral class
|
|
/// (which advances nothing). The local-refusal counterpart stays neutral (NativeAbLegacyRefusedWriteTests).
|
|
/// </summary>
|
|
/// <param name="general">The CIP general status of the envelope reply, or 0 for a PCCC STS 0x10 reply.</param>
|
|
[Theory]
|
|
[InlineData((byte)0x08)]
|
|
[InlineData((byte)0x1C)]
|
|
[InlineData((byte)0x20)]
|
|
[InlineData((byte)0x00)]
|
|
public async Task A_wire_Unsupported_reply_is_an_answered_refusal(byte general)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var server = general == 0 ? StsServer(0x10) : CipEnvelopeServer(general);
|
|
var driver = await DriverAsync(server);
|
|
var before = driver.GetHealth().LastSuccessfulRead;
|
|
|
|
var result = (await driver.ReadAsync(["T"], ct)).Single();
|
|
|
|
result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
|
var health = driver.GetHealth();
|
|
health.State.ShouldBe(DriverState.Healthy);
|
|
health.LastSuccessfulRead.ShouldNotBeNull();
|
|
health.LastSuccessfulRead.ShouldNotBe(before);
|
|
await driver.ShutdownAsync(ct);
|
|
server.HandlerErrors.ShouldBeEmpty();
|
|
}
|
|
#pragma warning restore OTOPCUA0001
|
|
}
|