fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s

A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-08-01 15:20:07 -04:00
parent c2c3c52274
commit 6b3da93151
21 changed files with 585 additions and 51 deletions
@@ -12,10 +12,12 @@ clone needs Docker Desktop and nothing else.
|---|---|
| [`Dockerfile`](Dockerfile) | Multi-stage: build from libplctag at pinned tag → copy binary into `debian:bookworm-slim` runtime image |
| [`docker-compose.yml`](docker-compose.yml) | One service per family (`controllogix` / `compactlogix` / `micro800` / `guardlogix`); all bind `:44818` |
| [`patches/`](patches/) | Applied to the libplctag checkout before the build — see [Local patches](#local-patches) |
| [`patches/`](patches/) | Applied to the libplctag checkout before the build, in filename order — see [Local patches](#local-patches) |
## Local patches
### 0001 — serve the Symbol Object listing
`patches/0001-ab_server-serve-cip-symbol-object-listing.patch` teaches `ab_server`
to answer **CIP service 0x55 (Get Instance Attribute List) on class 0x6B**, the
Symbol Object listing a client reads through the `@tags` pseudo-tag to enumerate
@@ -49,7 +51,28 @@ Partial-transfer paging is exercised the same way by starting `ab_server` with a
few hundred tags: all of them should come back, with no duplicates and no gaps
at the page boundaries.
**Bumping `LIBPLCTAG_TAG` means re-checking the patch against the new tag.** It
### 0002 — answer "tag does not exist" the way a controller does
`patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch` changes the reply
to an unresolvable tag path from CIP **0x03** (invalid parameter) to CIP **0x04**
(*IOI could not be deciphered or tag does not exist*), which is what a Logix
controller returns. libplctag translates the two very differently — `ErrorBadParam`
versus `ErrorNotFound` — and every client that classifies by status code reads the
first as "your request was malformed" and the second as "the controller answered,
that tag isn't there."
The distinction is load-bearing here: the driver's Test Connect probe falls back to a
name no controller defines, so **"tag not found" is the successful answer** when a
device has no authored tags yet. Before this patch the fixture reported a perfectly
healthy sim as a failed handshake, and — worse — the discrepancy was written down as
a fixture limitation, which hid a real driver defect behind it for as long as it
stood (`AbCipDriverProbe.IsReachableException` matched the native C spellings, which
the .NET wrapper never produces, so the reachable classification could not fire at
all). `AbCip_WithNoAuthoredTags_ReportsTheControllerReachable` in
`Host.IntegrationTests` is the pin, and it needs the fixture **rebuilt** from this
patch, not merely running.
**Bumping `LIBPLCTAG_TAG` means re-checking both patches against the new tag.** It
is a concrete tag rather than the `release` branch precisely so that this stays
a deliberate act — the branch moves, and the Dockerfile previously called it
"pinned" while tracking it.
@@ -117,11 +140,6 @@ place means updating both.
unit tests with golden byte buffers. The Symbol Object patch above lists
symbols, including their types; it does not add Template Object support, so a
browsed UDT still cannot be expanded into members here.
- **No `@raw_cpu_type`** — the driver's Test Connect probe falls back to that
pseudo-tag when a device has no authored tags, and `ab_server` answers
`ErrorBadParam`. So **Test Connect reports failure against this fixture even
when the device is fine**, and browse works regardless. Author one tag and the
probe uses it instead.
- **Controller-scope listing only** — a program-scope listing arrives with a
leading symbolic segment naming the program, and `ab_server` has no notion of
programs. `Program:`-scoped tags cannot be browsed here.
@@ -0,0 +1,30 @@
diff --git a/src/tools/ab_server/cip.c b/src/tools/ab_server/cip.c
index 68a5794..f286504 100644
--- a/src/tools/ab_server/cip.c
+++ b/src/tools/ab_server/cip.c
@@ -746,7 +746,11 @@ slice_s handle_read_request(uint8_t cip_service, slice_s cip_service_path, slice
if(!parse_tag_path(cip_service_path, plc, &tag, &num_indexes, &(indexes[0]))) {
log_info("Unable to parse tag path:");
log_info_slice(cip_service_path);
- return make_cip_log_error(output, cip_service, CIP_ERR_INVALID_PARAM, false, 0);
+ /* A Logix controller answers an unresolvable tag path with 0x04 ("IOI could not be
+ * deciphered or tag does not exist"), which clients read as "tag not found" and
+ * distinguish from a malformed request. Answering 0x03 instead reported a live
+ * controller as a failed handshake to any client that classifies by status code. */
+ return make_cip_log_error(output, cip_service, CIP_ERR_PATH_SEGMENT, false, 0);
}
/* Record request arrival time for latency tracking */
@@ -916,7 +920,11 @@ slice_s handle_write_request(uint8_t cip_service, slice_s cip_service_path, slic
if(!parse_tag_path(cip_service_path, plc, &tag, &num_indexes, &(indexes[0]))) {
log_info("Unable to parse tag path:");
log_info_slice(cip_service_path);
- return make_cip_log_error(output, cip_service, CIP_ERR_INVALID_PARAM, false, 0);
+ /* A Logix controller answers an unresolvable tag path with 0x04 ("IOI could not be
+ * deciphered or tag does not exist"), which clients read as "tag not found" and
+ * distinguish from a malformed request. Answering 0x03 instead reported a live
+ * controller as a failed handshake to any client that classifies by status code. */
+ return make_cip_log_error(output, cip_service, CIP_ERR_PATH_SEGMENT, false, 0);
}
/* Record request arrival time for latency tracking */
@@ -0,0 +1,44 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Pins the one property of <see cref="AbCipDataTypeExtensions.ToDriverDataType"/> that another
/// subsystem depends on: it is LOSSY, so it cannot be run backwards.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDataTypeExtensionsTests
{
/// <summary>
/// Four distinct Logix integer widths leave the driver as one driver-agnostic type. This is why a
/// browse-commit carries the native type instead of deriving it from the leaf's
/// <see cref="DriverDataType"/> (Gitea #536) — a reverse map would have to pick one width for all
/// four, and picking wrong reads the wrong number of bytes with no error raised anywhere.
/// </summary>
[Fact]
public void The_integer_widths_collapse_which_is_why_the_native_type_is_carried()
{
var widths = new[]
{
AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt,
AbCipDataType.USInt, AbCipDataType.UInt,
};
widths.Select(t => t.ToDriverDataType()).Distinct().ShouldHaveSingleItem()
.ShouldBe(DriverDataType.Int32);
}
/// <summary>
/// The falsifying half: an unsigned 32-bit tag does NOT collapse with the rest, so a test that only
/// showed "several types share an output" would pass against a map that collapsed everything.
/// </summary>
[Fact]
public void The_wider_unsigned_types_stay_distinct()
{
AbCipDataType.UDInt.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
}
}
@@ -34,6 +34,36 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>
/// Every emitted variable states its Logix type, so a browse-commit binds the tag with the type
/// the controller has rather than the tag model's <c>DInt</c> default (Gitea #536). The
/// driver-agnostic <see cref="DriverAttributeInfo.DriverDataType"/> cannot serve — <c>SInt</c>,
/// <c>Int</c> and <c>DInt</c> all arrive there as <see cref="DriverDataType.Int32"/>.
/// </summary>
[Fact]
public async Task Emitted_variables_state_their_Logix_type_for_the_commit_path()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
RawTags = AbCipRawTags.From(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.Real),
new AbCipTagDefinition("Count", "ab://10.0.0.5/1,0", "C", AbCipDataType.Int)),
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.NativeDataType.ShouldBe("Real");
// Int and DInt are indistinguishable downstream — this pair is the whole reason the native
// name is carried, so assert both halves rather than only the one that differs by kind.
var count = builder.Variables.Single(v => v.BrowseName == "Count").Info;
count.NativeDataType.ShouldBe("Int");
count.DriverDataType.ShouldBe(DriverDataType.Int32);
}
/// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
[Fact]
public async Task Device_folder_displayname_falls_back_to_host_address()
@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Sockets;
using libplctag;
using Shouldly;
using Xunit;
@@ -144,4 +145,45 @@ public sealed class AbCipDriverProbeTests
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
result.Latency.ShouldBeNull();
}
// -------------------------------------------------------------------------
// 5. Reachable-vs-failed classification (offline — no controller needed)
// -------------------------------------------------------------------------
/// <summary>
/// The exception path classifies by the SAME rule as the status path. libplctag.NET builds
/// <see cref="LibPlcTagException"/> with <c>Status.ToString()</c>, so its message is
/// "ErrorNotFound" — this method used to look for the native C spelling "STATUS_NOT_FOUND",
/// which that message can never contain, so a controller that answered "tag does not exist"
/// was reported as a failed handshake. That is the answer every Test Connect gets on a device
/// with no authored tags, i.e. every device at the moment an operator first presses the button.
/// </summary>
/// <param name="status">The status libplctag threw on.</param>
/// <param name="expectedReachable">Whether the controller should be reported reachable.</param>
[Theory]
[InlineData(Status.ErrorNotFound, true)]
[InlineData(Status.ErrorNoMatch, true)]
[InlineData(Status.ErrorBadDevice, true)]
[InlineData(Status.ErrorTimeout, false)]
[InlineData(Status.ErrorBadConnection, false)]
[InlineData(Status.ErrorBadGateway, false)]
[InlineData(Status.ErrorBadParam, false)]
public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(Status status, bool expectedReachable)
{
// Exactly how NativeTagWrapper.ThrowIfStatusNotOk constructs it.
var ex = new LibPlcTagException(status.ToString());
AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable);
AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable);
}
/// <summary>
/// A message that names no status at all stays UNreachable rather than defaulting green — the
/// conservative half, and the one an over-eager "treat anything we cannot parse as reachable"
/// fix would break.
/// </summary>
[Fact]
public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable()
=> AbCipDriverProbe.IsReachableException(
new LibPlcTagException("something the wrapper has never said")).ShouldBeFalse();
}
@@ -36,6 +36,13 @@ public sealed class FocasCapabilityTests
// Writable field, because WireFocasClient.WriteAsync always returns BadNotWritable.
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// Each variable states its FOCAS type for the browse-commit path (Gitea #536). Byte is the
// case that matters: it reaches Info.DriverDataType as Int32, exactly like Int16 and Int32,
// so a commit deriving the type from there would read four PMC bytes for a one-byte tag.
var run = builder.Variables.Single(v => v.BrowseName == "Run").Info;
run.NativeDataType.ShouldBe("Byte");
run.DriverDataType.ShouldBe(DriverDataType.Int32);
}
// ---- ISubscribable ----
@@ -34,6 +34,12 @@ public sealed class TwinCATCapabilityTests
builder.Folders.ShouldContain(f => f.BrowseName == "ads://5.23.91.23.1.1:851" && f.DisplayName == "Mach1");
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Status").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
// Each variable states its IEC type, so a browse-commit binds the tag with the type the PLC
// declares rather than TwinCATTagConfigModel's DInt default (Gitea #536). It cannot be
// recovered from Info.DriverDataType — Byte/Int16/Int32 all land there as Int32.
builder.Variables.Single(v => v.BrowseName == "Status").Info.NativeDataType.ShouldBe("Bool");
builder.Variables.Single(v => v.BrowseName == "Speed").Info.NativeDataType.ShouldBe("DInt");
}
// ---- ISubscribable ----