fix(ablegacy): libplctag refuses MicroLogix function files locally instead of a comms fault (#592)
v2-ci / unit-tests (push) Has been cancelled

libplctag 2.6.0 cannot parse RTC/HSC/... names; tag creation failed with
PLCTAG_ERR_BAD_PARAM, which the status translation reported as BadConnection:
evicted handle, degraded health, counted toward marking a healthy device down.
The libplctag runtime now refuses function-file addresses before creating a
native tag (Unsupported -> BadNotSupported, health untouched), matching native.
Docs: function files are unsupported on both transports, not "unverified".

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-13 01:00:47 -05:00
parent 422ed50b9c
commit eec87d3f74
7 changed files with 136 additions and 19 deletions
+1 -1
View File
@@ -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
+17 -8
View File
@@ -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
@@ -1641,9 +1641,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag
&& (IsDeviceRefusal(ate.Status) || ate is AbDeviceReplyException { Status: AbStatus.Unsupported });
/// <summary>
/// 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.)
/// </summary>
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;
@@ -5,9 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// MicroLogix 1100/1400 function-file sub-element catalogue: the driver-surface type of
/// <c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ... Covers the commonly-addressed members per file — not exhaustive
/// (Rockwell defines 30+ on RTC alone). <b>Unverified on the wire:</b> 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). <b>Not readable today:</b> 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 (<c>BadNotSupported</c>, #592).
/// </summary>
public static class AbLegacyFunctionFile
{
@@ -21,6 +21,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
private readonly string? _writeRefusal;
/// <summary>Why EVERY operation on this tag is refused before the wire (<see cref="Refusal"/>), else null.</summary>
private readonly string? _refusal;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); 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);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
@@ -60,12 +64,16 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// <inheritdoc />
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.
/// <inheritdoc />
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
/// <param name="p">The tag's create parameters.</param>
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
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);
/// <summary>
/// The addresses libplctag cannot address at all, refused before a native tag is created: MicroLogix function
/// files (<c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ...). libplctag 2.6.0's <c>parse_pccc_file_type</c> has no function-file
/// letters (<c>RTC</c> reads as an R file followed by a non-digit, <c>HSC</c> falls to the default case), so tag
/// creation fails locally with <c>PLCTAG_ERR_BAD_PARAM</c> — which the status translation has no case for and so
/// reported as <see cref="AbStatus.BadConnection"/>: a comms fault that evicted the handle, degraded health and
/// counted toward marking a healthy device down. Refusing it as <see cref="AbStatus.Unsupported"/> answers
/// <c>BadNotSupported</c> with health untouched — the same answer the native transport gives (#592, 2026-11-13).
/// </summary>
/// <param name="p">The tag's create parameters.</param>
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
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;
/// <summary>
@@ -121,6 +145,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>True when every operation is refused before the wire (<see cref="Refusal"/>); no native tag is ever created.</summary>
internal bool IsRefused => _refusal is not null;
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
@@ -168,7 +168,10 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
private static bool IsFunctionFileName(string tagName)
/// <summary>True when <paramref name="tagName"/> names a MicroLogix function file (<c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ...).</summary>
/// <param name="tagName">The canonical tag name.</param>
/// <returns><see langword="true"/> for a function-file address.</returns>
internal static bool IsFunctionFileName(string tagName)
{
var colon = tagName.IndexOf(':');
return colon > 0 && AbLegacyAddress.IsFunctionFileLetter(tagName[..colon].ToUpperInvariant());
@@ -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;
/// <summary>
/// #592 (2026-11-13): libplctag 2.6.0 cannot parse a MicroLogix function-file name (<c>RTC:0.HR</c>, <c>HSC:0.ACC</c>),
/// so its tag creation failed locally with <c>PLCTAG_ERR_BAD_PARAM</c> — translated to <see cref="AbStatus.BadConnection"/>,
/// 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.
/// </summary>
[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();
}
/// <summary>Init, read and write all refuse without touching the network (the gateway is unroutable, the timeout 1 s).</summary>
[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<AbTransportException>(() => rt.InitializeAsync(ct))).Status.ShouldBe(AbStatus.Unsupported);
(await Should.ThrowAsync<AbTransportException>(() => rt.ReadAsync(ct))).Status.ShouldBe(AbStatus.Unsupported);
(await Should.ThrowAsync<AbTransportException>(() => 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)
/// <summary>Through the production libplctag factory: BadNotSupported, health unchanged.</summary>
[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
}