Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md
T
Joseph Doherty f85fc8fbeb fix(abcip): #611 review round 1 — native keeps BOOL type distinction, WriteWidthRefusal tests, doc caveats
- L2: WriteWidthRefusal leaves a BOOL over a wider value to a runtime that sees the CIP
  type (native): mistyped integer member -> BadTypeMismatch (as its read), 0xD3 BOOL-array
  DWORD -> BadNotSupported; libplctag keeps BadNotSupported for both (size only).
- L4: direct theory over WriteWidthRefusal (1/2/4/8 widths incl LINT/ULINT/LREAL, String/Dt,
  unknown size) + array/bit-selector exemptions with a non-vacuous control.
- L1: CLAUDE.md member/BIT-member writes are ab_server-proven only.
- L3: patch 0003 blind spots documented (Docker README, AbServer-Test-Fixture.md); nits.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:31:45 -04:00

198 lines
11 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.
### 0003 — member paths, and libplctag's pad byte
`patches/0003-ab_server-resolve-member-paths-against-dotted-flat-tags.patch` does two
things for the discovered-UDT-member write suite (`AbCipUdtMemberWriteTests`, Gitea #611):
- **Member paths.** A Logix member path (`Seg.Sub.Code`) arrives as one CIP symbolic
segment per member. ab_server has no structures, so the patch joins consecutive symbolic
segments with `.` and looks up a flat tag whose `--tag` name is that dotted path (the name
charset now admits `.`); element segments after the last member work as before
(`UdtSeg.Points[2]`). This proves the wire of a member read/write — not template offsets
and not BIT-member semantics (each `UdtSeg.*` BOOL is its own byte, not a bit of a hidden
host). The `controllogix` profile seeds `UdtSeg.ID` / `.Enable` / `.Ready` / `.RampTime` /
`.Sub.Code` / `.Points[4]`, modelled on the Z-35162 press's `UDT_TemperatureProfile_Segment`.
- **libplctag's pad byte.** libplctag pads an odd-length write payload (a 1-byte SINT / BOOL)
with one byte; stock ab_server counted it as data and refused every such write with CIP
`0x03` (libplctag `ErrorBadParam`), so no libplctag BOOL/SINT write could pass here. The
patch drops exactly one trailing pad byte when it alone would overrun an odd-sized target.
**Blind spots this patch introduces (accepted for a fixture):** (a) the pad trim also accepts a genuinely
wrong 2-byte payload aimed at a 1-byte tag, so ab_server can no longer catch a client over-writing an
odd-sized target by exactly one byte — the driver's own pre-write width check (`AbCipDriver.WriteWidthRefusal`)
covers our paths; (b) because `.` is legal in a `--tag` name, a malformed *single* symbolic segment
`"UdtSeg.ID"` (which a real Logix rejects) also resolves, via the original exact-match lookup —
`NativeAbCipTagRuntimeTests.A_member_path_write_names_the_member_and_sends_only_its_own_bytes` pins that our
encoder splits members into separate segments.
**Bumping `LIBPLCTAG_TAG` means re-checking every patch 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.
**Writes are opt-in.** Every write-capable test (today the Logix Emulate ALMD
test, which pulses `SimulateAlarm`) also requires `AB_SERVER_ALLOW_WRITES=1`,
and refuses while `AB_DIFF_ENDPOINT` is set. Simulator / Emulate runs set it;
**never** set it when `AB_SERVER_ENDPOINT` points at production hardware. The
ab_server controllogix matrix itself is read-only and needs no opt-in.
**Read-only differential (Task 18).** `Differential/` compares libplctag and the
native transport against one endpoint, configured only by `AB_DIFF_*`
variables (see `Differential/DifferentialTarget.cs`). Always run it with
`--filter "FullyQualifiedName~Differential"`, and keep the sibling suites'
discovery probes off-network with `AB_SERVER_ENDPOINT=127.0.0.1:1` when the
target is a real controller.
## 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` `UdtSeg.*` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server; the `UdtSeg.*` member tags (patch 0003) serve member-path writes only. `Pump_1_Speed`, `Line1_Count` and `Motor_Run` 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