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; /// /// Gitea #593 follow-up: a native write the runtime would refuse (a T/C/R status-bit sub-element such as /// T4:0.DN, or any PLC-5 write) is refused by the DRIVER before a runtime is created — so the /// refusal holds on a FIRST-TOUCH write too, with zero PCCC requests on the wire. /// /// /// Before the fix the write path created and initialised the tag's runtime first, and a native init is one /// READ. Against a device whose T/C/R file is missing — the ab_server fixture has none — that read /// failed, so the write surfaced as BadCommunicationError, evicted the handle and degraded health /// (the comms-loss watchdog's input) instead of the documented BadNotSupported. These tests run the /// PRODUCTION over a real socket to a /// that, like ab_server, only has an N7 file, and count the PCCC requests it receives. /// [Trait("Category", "Unit")] public sealed class NativeAbLegacyRefusedWriteTests { private static readonly byte[] FixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21]; /// A PCCC-only fake: N7 exists (reads answer one zero word, writes succeed); every other file answers /// "file does not exist" (STS F0 / EXT 06 → NotFound), as a device without that file would. private sealed class N7OnlyServer : IAsyncDisposable { private int _pccc; public FakeEipServer Server { get; } = new() { ZeroReplyContext = true }; /// Execute PCCC requests received (reads AND writes). public int PcccRequests => Volatile.Read(ref _pccc); public N7OnlyServer() { Server.Handler = req => { if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null; var cip = FakeEipServer.RequestCip(req); Interlocked.Increment(ref _pccc); // cip: 4B 02 20 67 24 01 | id(7) | cmd 0F, sts, tns(2), fnc, byteCount, file#, fileType, element, sub var fileNumber = cip[19]; byte[] head = [0xCB, 0x00, 0x00, 0x00, .. FixedId, 0x4F]; return fileNumber == 7 ? FakeEipServer.RRReply(req, [.. head, 0x00, cip[15], cip[16], .. cip[17] == 0xA2 ? new byte[] { 0, 0 } : []]) : FakeEipServer.RRReply(req, [.. head, 0xF0, cip[15], cip[16], 0x06]); // EXT 06: file does not exist }; } public ValueTask DisposeAsync() => Server.DisposeAsync(); } // These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience // wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test. #pragma warning disable OTOPCUA0001 private static async Task DriverAsync( N7OnlyServer s, AbLegacyPlcFamily family, params AbLegacyTagDefinition[] tags) { var host = $"ab://127.0.0.1:{s.Server.Port}/1,0"; var driver = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(host, family)], RawTags = AbLegacyRawTags.Entries(tags.Select(t => t with { DeviceHostAddress = host }).ToArray()), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, }, "drv-refused-write", new NativeAbLegacyTagFactory(new DeviceSessionPool())); await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); return driver; } private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type, bool writable = false) => new(name, "", address, type, Writable: writable); /// /// A first-touch status-bit write is BadNotSupported with nothing on the wire and health unchanged, even /// though the device has no such file. The positive control (an N7 read on the same driver afterwards) is /// what proves the fake counts requests — and that the refusal left the connection usable. /// [Theory] [InlineData("T4:0.DN", AbLegacyDataType.Bit)] [InlineData("C5:0.CU", AbLegacyDataType.Bit)] [InlineData("R6:0.EN", AbLegacyDataType.Bit)] [InlineData("T4:0.TT", AbLegacyDataType.Int)] // the refusal follows the ADDRESS, not the authored type [InlineData("C5:0.DN", AbLegacyDataType.TimerElement)] public async Task A_first_touch_status_bit_write_is_BadNotSupported_with_nothing_on_the_wire( string address, AbLegacyDataType type) { var ct = TestContext.Current.CancellationToken; await using var s = new N7OnlyServer(); var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500, Tag("Scratch", "N7:5", AbLegacyDataType.Int), Tag("Bit", address, type, writable: true)); var health = driver.GetHealth(); var results = await driver.WriteAsync([new WriteRequest("Bit", true)], ct); results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); s.PcccRequests.ShouldBe(0); driver.GetHealth().ShouldBe(health); // Positive control: the same driver does reach this server, and the refusal did not poison anything. (await driver.ReadAsync(["Scratch"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); s.PcccRequests.ShouldBeGreaterThan(0); // init read + read driver.GetHealth().State.ShouldBe(DriverState.Healthy); await driver.ShutdownAsync(ct); s.Server.HandlerErrors.ShouldBeEmpty(); } /// A PLC-5 write (including a bit-within-word RMW, which would otherwise READ the parent word /// first) is refused with nothing on the wire. [Theory] [InlineData("N7:5", AbLegacyDataType.Int, (short)7)] [InlineData("N7:5/3", AbLegacyDataType.Bit, true)] public async Task A_first_touch_PLC5_write_is_BadNotSupported_with_nothing_on_the_wire( string address, AbLegacyDataType type, object value) { var ct = TestContext.Current.CancellationToken; await using var s = new N7OnlyServer(); var driver = await DriverAsync(s, AbLegacyPlcFamily.Plc5, Tag("W", address, type, writable: true)); var health = driver.GetHealth(); (await driver.WriteAsync([new WriteRequest("W", value)], ct)).Single().StatusCode .ShouldBe(AbLegacyStatusMapper.BadNotSupported); s.PcccRequests.ShouldBe(0); driver.GetHealth().ShouldBe(health); await driver.ShutdownAsync(ct); } /// Positive control for the refusal's scope: an ordinary SLC write still reaches the wire, so the /// pre-check is not swallowing every write (the T/C/R word sub-element case is /// NativeAbLegacyTagRuntimeTests.A_word_sub_element_write_through_the_driver_sends_a_typed_write). [Fact] public async Task An_SLC_word_write_still_reaches_the_wire() { var ct = TestContext.Current.CancellationToken; await using var s = new N7OnlyServer(); var driver = await DriverAsync(s, AbLegacyPlcFamily.Slc500, Tag("W", "N7:5", AbLegacyDataType.Int, writable: true)); (await driver.WriteAsync([new WriteRequest("W", (short)7)], ct)).Single().StatusCode .ShouldBe(AbLegacyStatusMapper.Good); s.PcccRequests.ShouldBeGreaterThanOrEqualTo(1); await driver.ShutdownAsync(ct); } #pragma warning restore OTOPCUA0001 [Theory] [InlineData("slc500", "T4:0.DN", true)] [InlineData("slc500", "C5:0.UA", true)] [InlineData("micrologix", "R6:0.FD", true)] [InlineData("slc500", "T4:0.PRE", false)] [InlineData("slc500", "T4:0.ACC", false)] [InlineData("slc500", "R6:0.POS", false)] [InlineData("slc500", "N7:0/3", false)] [InlineData("slc500", "B3:0/0", false)] [InlineData("slc500", "N7:0", false)] [InlineData("plc5", "N7:0", true)] [InlineData("plc5", "T4:0.PRE", true)] [InlineData("slc500", "T4:0.NOPE", false)] // unknown sub-element: refused by the runtime's own address check [InlineData("slc500", "garbage", false)] public void The_write_refusal_predicate_covers_status_bits_and_PLC5_only(string plc, string tag, bool refused) { var p = new AbLegacyTagCreateParams("h", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(1)); (NativeAbLegacyTagRuntime.WriteRefusal(p) is not null).ShouldBe(refused); (new NativeAbLegacyTagFactory().WriteRefusal(p) is not null).ShouldBe(refused); } }