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; /// /// Lane K F3 (AbLegacy F8, review 07 r0 I4), end to end over a real socket with the PRODUCTION /// : 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, LastSuccessfulRead 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, /// LastSuccessfulRead advanced. /// [Trait("Category", "Unit")] public sealed class NativePcccBridgeStatusHealthTests { private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21]; /// A fake whose Execute PCCC envelope itself is refused with CIP general status . 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; } /// A PCCC fake that answers every request with one fixed STS. 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; } /// /// A manual clock the driver stamps health from (lmxopcua#768). Init stamps ; each test /// moves it to before reading, so an "advanced" LastSuccessfulRead is provably /// the read's stamp (it used to be DateTime.UtcNow twice microseconds apart, equal ~70% of runs on a /// coarse clock, lmxopcua#767). /// private static readonly DateTime InitTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime ReadTime = InitTime.AddSeconds(1); #pragma warning disable OTOPCUA0001 // the driver is called directly to pin its OWN classification (no resilience wrap) private static async Task DriverAsync(FakeEipServer server, Func clock) { 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())) { Clock = clock, }; await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); return driver; } /// STS 0x30 from a bridge: a comms fault, never evidence the device answered. [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 now = InitTime; var driver = await DriverAsync(server, () => now); var before = driver.GetHealth().LastSuccessfulRead; before.ShouldBe(InitTime); now = ReadTime; 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(); } /// Positive control: STS 0x60 is the live device refusing the tag — answered, so Healthy and advanced. [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 now = InitTime; var driver = await DriverAsync(server, () => now); var before = driver.GetHealth().LastSuccessfulRead; before.ShouldBe(InitTime); now = ReadTime; var result = (await driver.ReadAsync(["T"], ct)).Single(); result.StatusCode.ShouldBe(AbOpcUaStatusMap.BadNotWritable); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Healthy); health.LastSuccessfulRead.ShouldBe(ReadTime); await driver.ShutdownAsync(ct); server.HandlerErrors.ShouldBeEmpty(); } /// /// 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, LastSuccessfulRead advanced. It must not land in the "refused before the wire" neutral class /// (which advances nothing). The local-refusal counterpart stays neutral (NativeAbLegacyRefusedWriteTests). /// /// The CIP general status of the envelope reply, or 0 for a PCCC STS 0x10 reply. [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 now = InitTime; var driver = await DriverAsync(server, () => now); var before = driver.GetHealth().LastSuccessfulRead; before.ShouldBe(InitTime); now = ReadTime; var result = (await driver.ReadAsync(["T"], ct)).Single(); result.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); var health = driver.GetHealth(); health.State.ShouldBe(DriverState.Healthy); health.LastSuccessfulRead.ShouldBe(ReadTime); await driver.ShutdownAsync(ct); server.HandlerErrors.ShouldBeEmpty(); } #pragma warning restore OTOPCUA0001 }