Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md
T
Joseph Doherty 6b3da93151
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
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
2026-08-01 15:20:07 -04:00

159 lines
8.1 KiB
Markdown

# AB CIP integration-test fixture — `ab_server` (Docker)
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` — a
MIT-licensed C program that emulates a ControlLogix / CompactLogix CIP
endpoint over EtherNet/IP. Docker is the only supported launch path;
`ab_server` ships as a source-only tool under libplctag's
`src/tools/ab_server/` so the Dockerfile's multi-stage build is the only
reproducible way to get a working binary across developer boxes. A fresh
clone needs Docker Desktop and nothing else.
| File | Purpose |
|---|---|
| [`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, 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
a controller's symbol table. Upstream answers `unsupported`, which is what left
the AbCip controller browse unverifiable (Gitea #468) — the browse binding was
proven alive, but no backend ever returned a tag.
Two things worth knowing before touching it:
- **The reply layout is fixed to the attribute set libplctag asks for** —
`{0x02 symbol type, 0x07 element size, 0x08 array dimensions, 0x01 symbol
name}`. A request for a different set is refused rather than answered with a
differently-shaped reply the client would misparse.
- **Entries are packed with no padding.** The next entry begins on the byte
after the last name byte, even on an odd offset. This is not a detail of the
fixture: it is what a controller does, and assuming otherwise is exactly the
decoder defect the gate found.
Correctness is checked against **libplctag's own `list_tags_logix`** client, not
against our decoder — our decoder is the thing under test, so agreeing with it
would prove nothing. `list_tags_logix` is the reference implementation exercised
against real Logix hardware:
```bash
git clone --depth 1 --branch v2.7.0 https://github.com/libplctag/libplctag.git
cd libplctag && cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel
./build/bin_dist/list_tags_logix 10.100.0.35 1,0 # expect all nine tags, "SUCCESS!"
```
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.
### 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.
## Run
From the repo root:
```powershell
# ControlLogix — widest-coverage profile
docker compose -f tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\Docker\docker-compose.yml --profile controllogix up
# Per-family
docker compose -f tests\...\Docker\docker-compose.yml --profile compactlogix up
docker compose -f tests\...\Docker\docker-compose.yml --profile micro800 up
docker compose -f tests\...\Docker\docker-compose.yml --profile guardlogix up
```
Detached + stop:
```powershell
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix down
```
First run builds the image (~3-5 minutes — clones libplctag + compiles
`ab_server` + its dependencies). Subsequent runs are fast because the
multi-stage build layer-caches the checkout + compile.
## Endpoint
- Default: `localhost:44818` (EtherNet/IP standard; non-privileged)
- Override with `AB_SERVER_ENDPOINT=host:port` to point at a real PLC.
## Run the integration tests
In a separate shell with a container up:
```powershell
cd C:\Users\dohertj2\Desktop\lmxopcua
dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
```
`AbServerFixture` TCP-probes `localhost:44818` at collection init +
records a skip reason when unreachable, so tests stay green on a fresh
clone without the container running. Tests use `[AbServerFact]` /
`[AbServerTheory]` which check the same probe.
## What each family seeds
Tag sets match `AbServerProfile.cs` exactly — changing seeds in one
place means updating both.
| Family | Seeded tags | Notes |
|---|---|---|
| ControlLogix | `TestDINT` `TestREAL` `TestBOOL` `TestSINT` `TestString` `TestArray` `Pump_1_Speed` `Line1_Count` `Motor_Run` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server. The last three serve the controller-browse gate and are additive — the read/write suites key off the `Test*` tags. `Motor_Run` (9), `Line1_Count` (11) and `TestArray` (9) have **odd-length names on purpose**: entries are unpadded, so an odd name puts every following entry on an odd offset, and a decoder that assumes even alignment loses everything behind it |
| CompactLogix | `TestDINT` `TestREAL` `TestBOOL` | Narrow ConnectionSize cap enforced driver-side; ab_server accepts any size |
| Micro800 | `TestDINT` `TestREAL` | ab_server has no `micro800` mode; falls back to `controllogix` emulation |
| GuardLogix | `TestDINT` `SafetyDINT_S` | ab_server has no safety subsystem; `_S` suffix triggers driver-side classification only |
## Known limitations
- **No UDT / CIP Template Object emulation** — `ab_server` covers atomic
types only. UDT reads + task #194 whole-UDT optimization verify via
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.
- **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.
- **Family-specific quirks trust driver-side code** — ab_server emulates
a generic Logix CPU; the ConnectionSize cap, empty-path unconnected
mode, and safety-partition write rejection all need lab rigs for
wire-level proof.
See [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
for the full coverage map.
## References
- [libplctag on GitHub](https://github.com/libplctag/libplctag)
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) — coverage map
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures