From e863e9f0e26f099726ddf75f83eb33427b54f402 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 07:28:52 -0400 Subject: [PATCH] fix(ablegacy): refuse native status-bit and PLC-5 writes before creating the runtime (#593) A first-touch native write to a T/C/R status-bit sub-element (T4:0.DN) created and initialised the tag's runtime before the runtime's write refusal could fire; the init is one READ, so against a device lacking the file (the ab_server fixture has no T/C/R) it surfaced as BadCommunicationError + Degraded health instead of BadNotSupported. The bit-RMW path was not involved: AbLegacyAddress rejects sub-element + /N, so a status bit's BitIndex is always null. NativeAbLegacyTagRuntime.WriteRefusal is now the single predicate (status bit by address whatever the authored type; any PLC-5 write), asked by the driver via IAbLegacyTagFactory.WriteRefusal (default null, so libplctag is unchanged) before EnsureTagRuntimeAsync and ahead of the bit-RMW branch. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- docs/drivers/AbLegacy.md | 19 +- .../AbLegacyDriver.cs | 34 +++- .../IAbLegacyTagRuntime.cs | 10 + .../NativeAbLegacyTagFactory.cs | 3 + .../NativeAbLegacyTagRuntime.cs | 52 +++++- .../AbLegacyUnsupportedPathTests.cs | 11 ++ .../NativeAbLegacyRefusedWriteTests.cs | 173 ++++++++++++++++++ 7 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyRefusedWriteTests.cs diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index dc243c4e..9d088d87 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -142,9 +142,17 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real controller. - ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN` - and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is - unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would - race the PLC updating TT/DN. Reads of status bits are unaffected. + and the rest return **`BadNotSupported`** whatever the tag's authored data type (the refusal keys on the + address); nothing is written to the PLC; health is unchanged. The driver answers the refusal **before it + creates the tag's runtime**, so a first-touch write sends nothing at all — not even the init read, which + against a controller without that file used to surface as `BadCommunicationError` + Degraded health (#593 + follow-up). PLC-5 writes are refused the same way. libplctag performs a masked bit write here; a native + read-modify-write of the control word would race the PLC updating TT/DN. Reads of status bits are unaffected. + - ⚠️ **On libplctag (the default) a status-bit write is NOT refused** — it goes to the controller as a write to the + timer/counter/control element (libplctag's bit-tag write; not exercised on a wire — `ab_server` has no T/C/R + files). Nothing in authoring prevents it: the address is free text, + any `dataType` may be chosen (a status bit is naturally authored `Bit`), and `writable` defaults to `true`. + Author status-bit tags with `"writable": false` unless writing them is genuinely intended. - **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`. `LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init. - **ST (string) byte order is unverified** (no capture); string values may be byte-swapped. @@ -175,7 +183,10 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init. - Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the - control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes + control word); nothing goes on the wire — the driver refuses before creating the tag's runtime, so not even an + init read is sent (`NativeAbLegacyTagRuntime.WriteRefusal` is the single predicate, asked through + `IAbLegacyTagFactory.WriteRefusal`). Pinned offline by `NativeAbLegacyRefusedWriteTests` and on `ab_server` by + `AbLegacyUnsupportedPathTests` (whose SLC500 fixture has no T/C/R files, which is what makes it discriminating). Sub-element arrays are Unsupported natively. L-file bit indexes above 15 are allowed. - **ST (string) file layout is uncaptured**, so the string byte order is unverified natively. - Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 0a11cdb6..d7f89c77 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -428,11 +428,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { var parsed = AbLegacyAddress.TryParse(def.Address); + // A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN, + // or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init + // READ, and against a device lacking that file the read failure surfaced as a comms fault — + // BadCommunicationError, evicted handle, Degraded health — instead of the refusal (#593 follow-up). + // Ahead of the bit-RMW branch too, which would otherwise read the parent word first. + if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); + continue; + } + // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements - // don't reach this path because they're not Bit typed. NOTE: an Input-image (I) write is sent + // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a + // sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent // to the PLC like any other write — the device drives the input image from physical inputs and // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit) @@ -817,6 +829,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover keep(); } + /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so + /// the refusal is asked about exactly the runtime a write would otherwise create. + private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => + new(Gateway: device.ParsedAddress.Gateway, + Port: device.ParsedAddress.Port, + CipPath: device.EffectiveCipPath, + LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, + TagName: parsed.ToLibplctagName(), + Timeout: _options.Timeout, + ElementCount: EffectiveArrayLength(def)); + private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { @@ -835,14 +858,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover ?? throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); - var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( - Gateway: device.ParsedAddress.Gateway, - Port: device.ParsedAddress.Port, - CipPath: device.EffectiveCipPath, - LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, - TagName: parsed.ToLibplctagName(), - Timeout: _options.Timeout, - ElementCount: EffectiveArrayLength(def))); + var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) .ConfigureAwait(false); return runtime; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs index 9f4872d6..d3fdd1df 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -58,6 +58,16 @@ public interface IAbLegacyTagFactory /// The tag creation parameters. /// The created tag runtime instance. IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams); + + /// + /// Why a runtime from this factory would refuse a WRITE to before anything + /// reached the wire, or when it would attempt it. The driver asks this BEFORE it + /// creates or initialises a runtime for a write, so a refused write never costs the init read either — + /// which against a device lacking the file would otherwise surface as a comms fault. + /// + /// The parameters the write's runtime would be created with. + /// The refusal reason, or when the write is attempted. + string? WriteRefusal(AbLegacyTagCreateParams createParams) => null; } /// The PLC gateway host (IP or DNS). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs index 3048d8ba..ea90e634 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs @@ -11,4 +11,7 @@ internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : /// public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) => new NativeAbLegacyTagRuntime(createParams, _pool); + + /// + public string? WriteRefusal(AbLegacyTagCreateParams createParams) => NativeAbLegacyTagRuntime.WriteRefusal(createParams); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs index 1f43cb28..6ba2c2e1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -31,6 +31,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime private readonly int _elementSize; private readonly int _count; private readonly byte[] _buffer; + private readonly string? _writeRefusal; private int _status; /// Creates a runtime that leases the shared connection for its device from . @@ -64,6 +65,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor); BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial); _count = Math.Max(1, p.ElementCount); + _writeRefusal = WriteRefusal(p); try { @@ -113,6 +115,45 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime }; } + /// + /// The single authority for the writes this runtime refuses before the wire, or + /// when it attempts the write. throws on it, and the driver asks it (via + /// ) before creating a runtime, so a refused write never + /// costs the init read either (Gitea #593 follow-up). + /// + /// + /// PLC-5: every write — no capture of the PLC-5 write frames exists. + /// Status-bit sub-elements (T4:0.DN, C5:0.CU, R6:0.EN…, whatever the tag's + /// authored type): libplctag does a masked bit write (0xAB) here; there is no capture of it, and a + /// read-modify-write would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN. + /// An address this runtime cannot parse is NOT a write refusal: the runtime's own address check refuses + /// every operation on it, read included, with the same . + /// + /// The tag's create parameters (family + address). + /// The refusal reason, or . + internal static string? WriteRefusal(AbLegacyTagCreateParams p) + { + if (p.LibplctagPlcAttribute == "plc5") return "PLC-5 writes are not capture-verified."; + return StatusBitOf(p.TagName) is not null + ? "Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport." + : null; + } + + /// The control-word bit a status-bit sub-element address names, else null (word sub-elements, + /// plain addresses and unparseable ones all yield null). + private static int? StatusBitOf(string tagName) + { + if (AbLegacyAddress.TryParse(tagName) is not { SubElement: { } sub } a) return null; + try + { + return PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, sub).Bit; + } + catch (AbTransportException) + { + return null; + } + } + private static PcccAddress ToPcccAddress(string tagName) { var a = AbLegacyAddress.TryParse(tagName) @@ -218,14 +259,9 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime private async Task WriteCoreAsync(CancellationToken ct) { - if (_plc == "plc5") - throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified."); - // libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write - // would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse - // before anything reaches the wire. - if (StatusBit is not null) - throw new AbTransportException(AbStatus.Unsupported, - "Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport."); + // Refuse before anything reaches the wire (see WriteRefusal for why each case is refused). + if (_writeRefusal is not null) + throw new AbTransportException(AbStatus.Unsupported, _writeRefusal); var (chunk, address) = Plan(); for (var done = 0; done < _count; done += chunk) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs index 7f04d749..e387dc75 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyUnsupportedPathTests.cs @@ -81,6 +81,13 @@ public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim) } /// A native status-bit sub-element write is refused locally on the families that carry T/C/R files. + /// + /// ⚠️ The fixture has NO T/C/R files (the SLC500 ab_server serves N7/F8/B3/L19 only), and that is what + /// makes this test discriminating: the write is FIRST-TOUCH (nothing reads the status-bit tag beforehand), + /// so a driver that created and initialised the tag's runtime before refusing would issue an init READ of a + /// missing file and report BadCommunicationError + Degraded health — exactly how this test first + /// failed (#593 follow-up). It passes only if the driver refuses before any wire request for the tag. + /// /// The status-bit address. [AbLegacyTheory] [InlineData("T4:0.DN")] @@ -103,5 +110,9 @@ public sealed class AbLegacyUnsupportedPathTests(AbLegacyServerFixture sim) results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + // The refusal left the connection usable. + (await drv.ReadAsync(["Scratch"], TestContext.Current.CancellationToken)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.Good); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyRefusedWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyRefusedWriteTests.cs new file mode 100644 index 00000000..e790eac0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyRefusedWriteTests.cs @@ -0,0 +1,173 @@ +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); + } +}