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

11 KiB

AB CIP integration-test fixture — ab_server (Docker)

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 Multi-stage: build from libplctag at pinned tag → copy binary into debian:bookworm-slim runtime image
docker-compose.yml One service per family (controllogix / compactlogix / micro800 / guardlogix); all bind :44818
patches/ Applied to the libplctag checkout before the build, in filename order — see 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:

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:

# 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:

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:

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 for the full coverage map.

References