- 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
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--tagname 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 (eachUdtSeg.*BOOL is its own byte, not a bit of a hidden host). Thecontrollogixprofile seedsUdtSeg.ID/.Enable/.Ready/.RampTime/.Sub.Code/.Points[4], modelled on the Z-35162 press'sUDT_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(libplctagErrorBadParam), 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:portto 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_servercovers 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_serverhas 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
- libplctag on GitHub
docs/drivers/AbServer-Test-Fixture.md— coverage mapdocs/v2/dev-environment.md§Docker fixtures