diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md index 26baeb186..bb72e4ddd 100644 --- a/.claude/rules/drivers.md +++ b/.claude/rules/drivers.md @@ -177,7 +177,7 @@ re-emit a concrete default. The defaults differ per driver (final review, 2026-1 only: a libplctag WRITE to any address the translation changes (`S:`/`O:`/`I:` incl. bit writes, R `EN…FD`, `C…UA`) is refused `BadNotSupported` before any runtime is created (none could reach the PLC before — no write-surface widening on the default transport); libplctag T/C status-bit writes it parses itself are unchanged. - L/A files, function files, SLC, PLC-5 and `logixpccc` remain unverified natively. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning. + L/A files, SLC, PLC-5 and `logixpccc` remain unverified natively. MicroLogix function files are refused `BadNotSupported` on BOTH transports (libplctag cannot parse them; its local `ERR_BAD_PARAM` used to surface as a comms fault until 2026-11-13, #592). Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning. AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`). Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 01cea12a1..833749a3c 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -118,9 +118,15 @@ physical inputs and may reject the write. - **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI` (single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only. `AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown - members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator - coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so - it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence). + members default to Int32). ⚠️ **Neither transport can read them — they author and deploy, but every read and + write answers `BadNotSupported`.** The native transport has no file-type code for them, and libplctag 2.6.0's + `parse_pccc_file_type` has no function-file letters: `RTC` parses as an R file followed by a non-digit, `HSC`/`EII` + fall to its default case, so tag creation fails locally with `PLCTAG_ERR_BAD_PARAM` and nothing reaches the wire. + Until 2026-11-13 the libplctag adapter reported that as a **comms fault** (`BadCommunicationError`, evicted handle, + degraded health, counted toward marking a healthy device down); it now refuses the address before creating a tag + (`LibplctagLegacyTagRuntime.Refusal`), health untouched, the same answer native gives (#592). So this is a + missing capability, not an unverified one: a real-controller read would show nothing new. Supporting them means + a native PCCC encoding for function files (a feature decision); libplctag removal (P6) loses nothing here. - **Indirect addressing (#247 parse, #646 runtime).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another address) parse, one level deep (the inner address must be direct, and carries the family's octal rule). At runtime the driver reads the inner address first (same runtime factory, so libplctag and Native alike), requires @@ -187,8 +193,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected - **Known limits of Native** (why it is opt-in): - ⚠️ **Real-hardware evidence is one MicroLogix 1400** (Z-33323, 2026-10-09) — see [Wire evidence](#wire-evidence-native-pccc): S (incl. `S:0`), N, F, B, T, C, R, ST, O, I reads, T/C/R - sub-elements and status bits, and N/F/B-bit writes all correct. **L and A files, function files, SLC 5/05, - PLC-5 and `logixpccc` are unverified.** Opt in per driver after checking it against the real controller. + sub-elements and status bits, and N/F/B-bit writes all correct. **L and A files, SLC 5/05, + PLC-5 and `logixpccc` are unverified; function files are unsupported on both transports.** 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`** 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 @@ -255,7 +261,9 @@ such as `O:0/3` is refused ahead of the parent-word read-modify-write) and the r sent, health unchanged (`LibplctagLegacyTagRuntime.WriteRefusal`, asked through `IAbLegacyTagFactory.WriteRefusal`, the same plumbing as the native refusal). Those writes never worked on libplctag, so nothing is lost; the native transport (opt-in) does write `S`/`O`/`I`. T/C status bits libplctag parses itself keep their pre-existing write behaviour (note above). -Not tested on hardware: L and A files (no press has them), function files, PLC-5, SLC, `logixpccc`. +Not tested on hardware: L and A files (no press has them), PLC-5, SLC, `logixpccc`. Function files are not +supported by either transport (refused locally, nothing sent — see the function-file note above), so there is +nothing to test on hardware. Simulator evidence: @@ -265,7 +273,8 @@ Simulator evidence: | N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) | | F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) | | S (incl. `S:0`), B, T, C, R, ST, O, I | **real MicroLogix 1400 (above)**; L and A: none | -| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported | +| MicroLogix function files (RTC/HSC/...) | **not supported** — parsed and deployable; both transports refuse them before the wire (`BadNotSupported`, health untouched) | +| PLC-5 octal I/O | **none** — parsed only | | Bit-within-word write (`B3:0/5`) | `ab_server` SLC500 + MicroLogix, both transports (`AbLegacyBitWriteTests`): the driver RMWs the parent word, only the addressed bit changes (the parent-word read-modify-write is not atomic against ladder writes to the same word). PLC-5 native refuses it (`BadNotSupported`, #571) | | Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`, status bits) | **real MicroLogix 1400 (above)** | @@ -273,7 +282,7 @@ Simulator evidence: ### Native known gaps - ⚠️ **One real controller.** A MicroLogix 1400 (Z-33323) passed reads across S/N/F/B/T/C/R/ST/O/I and N/F/B-bit - writes; SLC 5/05, PLC-5, `logixpccc`, L/A files and function files are unverified. See + writes; SLC 5/05, PLC-5, `logixpccc` and L/A files are unverified; function files are unsupported (both transports). See [Wire evidence](#wire-evidence-native-pccc). - **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in 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 c37430770..45f1e05cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -1641,9 +1641,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag && (IsDeviceRefusal(ate.Status) || ate is AbDeviceReplyException { Status: AbStatus.Unsupported }); /// - /// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is - /// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and + /// Initializes a freshly created runtime. A runtime whose init is refused locally is still KEPT: the + /// refusal is deterministic and per-operation, so caching it avoids re-creating — and, for NATIVE, /// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before. + /// (A libplctag runtime refuses only before creating its native tag — e.g. a MicroLogix function file — + /// so keeping it holds no native handle.) /// private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct) { @@ -1651,7 +1653,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { await runtime.InitializeAsync(ct).ConfigureAwait(false); } - catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime) + catch (Exception ex) when (IsLocalRefusal(ex) + && runtime is NativeAbLegacyTagRuntime or LibplctagLegacyTagRuntime { IsRefused: true }) { keep(); throw; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs index a5f835b20..e1b1ff6ba 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyFunctionFile.cs @@ -5,9 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// /// MicroLogix 1100/1400 function-file sub-element catalogue: the driver-surface type of /// RTC:0.HR, HSC:0.ACC, ... Covers the commonly-addressed members per file — not exhaustive -/// (Rockwell defines 30+ on RTC alone). Unverified on the wire: the layouts are transcribed from the -/// MicroLogix instruction-set reference; neither transport has been captured against these files and the -/// native transport has no encoding for them at all. +/// (Rockwell defines 30+ on RTC alone). Not readable today: the layouts are transcribed from the +/// MicroLogix instruction-set reference; the native transport has no encoding for these files and libplctag +/// cannot parse their names, so both refuse them before the wire (BadNotSupported, #592). /// public static class AbLegacyFunctionFile { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 3fd98a763..099f6c571 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -21,6 +21,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime /// Why a write to this tag is refused before the wire (), else null. private readonly string? _writeRefusal; + /// Why EVERY operation on this tag is refused before the wire (), else null. + private readonly string? _refusal; + /// /// The status the native tag held when a failed initialisation destroyed it (see /// ); null while the wrapper's own handle is authoritative. @@ -37,6 +40,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime _tag = BuildTag(p); _count = Math.Max(1, p.ElementCount); _writeRefusal = WriteRefusal(p); + _refusal = Refusal(p); } /// Builds the (uninitialized) libplctag tag for . @@ -60,12 +64,16 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime /// public Task InitializeAsync(CancellationToken cancellationToken) => - RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken)); + _refusal is { } refusal + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, refusal)) + : RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken)); // libplctag initialises an uninitialised tag on its first read, so this path can orphan one too. /// public Task ReadAsync(CancellationToken cancellationToken) => - RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken)); + _refusal is { } refusal + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, refusal)) + : RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken)); // Throws the WriteRefusal refusal (Unsupported) before anything is sent — defence in depth; // the driver asks the factory first and never creates a runtime for a refused write. @@ -88,8 +96,24 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime /// The tag's create parameters. /// The refusal reason, or . internal static string? WriteRefusal(AbLegacyTagCreateParams p) => - LibplctagLegacyName.IsTranslated(p.TagName) + Refusal(p) ?? (LibplctagLegacyName.IsTranslated(p.TagName) ? $"Writes to '{p.TagName}' are not supported on the libplctag transport (its name is translated for libplctag; reads only)." + : null); + + /// + /// The addresses libplctag cannot address at all, refused before a native tag is created: MicroLogix function + /// files (RTC:0.HR, HSC:0.ACC, ...). libplctag 2.6.0's parse_pccc_file_type has no function-file + /// letters (RTC reads as an R file followed by a non-digit, HSC falls to the default case), so tag + /// creation fails locally with PLCTAG_ERR_BAD_PARAM — which the status translation has no case for and so + /// reported as : a comms fault that evicted the handle, degraded health and + /// counted toward marking a healthy device down. Refusing it as answers + /// BadNotSupported with health untouched — the same answer the native transport gives (#592, 2026-11-13). + /// + /// The tag's create parameters. + /// The refusal reason, or . + internal static string? Refusal(AbLegacyTagCreateParams p) => + NativeAbLegacyTagRuntime.IsFunctionFileName(p.TagName) + ? $"'{p.TagName}' is a MicroLogix function file; the libplctag transport cannot address function files." : null; /// @@ -121,6 +145,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime /// (0 if none was created). Tests only. internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag); + /// True when every operation is refused before the wire (); no native tag is ever created. + internal bool IsRefused => _refusal is not null; + /// The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only. internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag); 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 8c372e5d0..f54ad5b87 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -168,7 +168,10 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime } } - private static bool IsFunctionFileName(string tagName) + /// True when names a MicroLogix function file (RTC:0.HR, HSC:0.ACC, ...). + /// The canonical tag name. + /// for a function-file address. + internal static bool IsFunctionFileName(string tagName) { var colon = tagName.IndexOf(':'); return colon > 0 && AbLegacyAddress.IsFunctionFileLetter(tagName[..colon].ToUpperInvariant()); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagFunctionFileRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagFunctionFileRefusalTests.cs new file mode 100644 index 000000000..f18dcdf50 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagFunctionFileRefusalTests.cs @@ -0,0 +1,75 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// #592 (2026-11-13): libplctag 2.6.0 cannot parse a MicroLogix function-file name (RTC:0.HR, HSC:0.ACC), +/// so its tag creation failed locally with PLCTAG_ERR_BAD_PARAM — translated to , +/// i.e. a comms fault that evicted the handle, degraded health and counted toward marking the device down. The libplctag +/// runtime now refuses these addresses before creating a native tag, as the native transport already did. +/// +[Trait("Category", "Unit")] +public sealed class LibplctagFunctionFileRefusalTests +{ + private static AbLegacyTagCreateParams Params(string tag) => + new("192.0.2.1", 44818, "", "micrologix", tag, TimeSpan.FromSeconds(1)); + + [Theory] + [InlineData("RTC:0.HR", true)] + [InlineData("RTC:0.YR", true)] + [InlineData("HSC:0.ACC", true)] + [InlineData("DLS:0.EN", true)] + [InlineData("BHI:0.OS", true)] + [InlineData("STI:0.EN", true)] + [InlineData("N7:0", false)] + [InlineData("R6:0.LEN", false)] + [InlineData("ST14:0", false)] + [InlineData("S:0", false)] + public void Only_function_files_are_refused(string tag, bool refused) + { + (LibplctagLegacyTagRuntime.Refusal(Params(tag)) is not null).ShouldBe(refused); + if (refused) LibplctagLegacyTagRuntime.WriteRefusal(Params(tag)).ShouldNotBeNull(); + } + + /// Init, read and write all refuse without touching the network (the gateway is unroutable, the timeout 1 s). + [Fact] + public async Task The_runtime_refuses_every_operation_before_the_wire() + { + var ct = TestContext.Current.CancellationToken; + using var rt = new LibplctagLegacyTagRuntime(Params("RTC:0.HR")); + rt.IsRefused.ShouldBeTrue(); + (await Should.ThrowAsync(() => rt.InitializeAsync(ct))).Status.ShouldBe(AbStatus.Unsupported); + (await Should.ThrowAsync(() => rt.ReadAsync(ct))).Status.ShouldBe(AbStatus.Unsupported); + (await Should.ThrowAsync(() => rt.WriteAsync(ct))).Status.ShouldBe(AbStatus.Unsupported); + rt.NativeHandle.ShouldBe(0, "no native libplctag tag is ever created for a refused address"); + } + +#pragma warning disable OTOPCUA0001 // direct driver calls pin the driver's own status mapping (no resilience wrapper) + /// Through the production libplctag factory: BadNotSupported, health unchanged. + [Fact] + public async Task A_function_file_read_on_libplctag_is_BadNotSupported_with_health_unchanged() + { + var ct = TestContext.Current.CancellationToken; + const string host = "ab://192.0.2.1/"; + var driver = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.MicroLogix)], + RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Hr", host, "RTC:0.HR", AbLegacyDataType.Int)]), + Timeout = TimeSpan.FromSeconds(1), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = AbLegacyTransport.Libplctag, + }, "drv-libplctag-function-file", new LibplctagLegacyTagFactory()); + await driver.InitializeAsync("{}", ct); + var health = driver.GetHealth(); + + for (var i = 0; i < 3; i++) + (await driver.ReadAsync(["Hr"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + driver.GetHealth().ShouldBe(health); + await driver.ShutdownAsync(ct); + } +#pragma warning restore OTOPCUA0001 +}