# 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