c29f98ffe0
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
147 lines
7.0 KiB
C#
147 lines
7.0 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// InitializeAsync stamps <c>LastSuccessfulRead</c> with <c>DateTime.UtcNow</c> and the read stamps its own
|
|
/// <c>UtcNow</c> microseconds later; on a coarse/identical clock reading the two are equal, so an "advanced"
|
|
/// assertion (<c>ShouldNotBe(before)</c>) failed ~70% of runs (lmxopcua#767). Spin until the clock has strictly
|
|
/// passed the init stamp so any later stamp is provably newer. The assertion itself is unchanged.
|
|
/// </summary>
|
|
private static void WaitUntilClockPast(DateTime? stamp)
|
|
{
|
|
if (stamp is null) return;
|
|
var spin = new SpinWait();
|
|
while (DateTime.UtcNow <= stamp.Value) spin.SpinOnce();
|
|
}
|
|
|
|
#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;
|
|
WaitUntilClockPast(before);
|
|
|
|
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;
|
|
WaitUntilClockPast(before);
|
|
|
|
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
|
|
}
|