Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md
T
Joseph Doherty b085360454 test(ab-transport): non-zero array/BOOL captures, committed capture client, sim quirks
Review round 1 on the golden frames:
- Re-captured logix-read-dint-array16 (element i = ((i+1)<<24)|(i+1)) and
  logix-read-bool (= 1) so neither decode oracle is all zeros. ab_server rejects
  libplctag's own BOOL write with CIP 0x03 (two data bytes for a one-byte BOOL),
  so the BOOL is seeded by a raw UCMM Write Tag (client seed-bool).
- Committed the capture client as scripts/ab-capture/client (outside the slnx,
  CPM off, libplctag 1.5.2 pinned); capture.sh gains a `client` subcommand.
- README documents simulator-only behaviour: zeroed reply sender context, STRING
  typed D0 00 instead of A0 02 + handle, and the BOOL write refusal.
- capture.sh no longer claims UnregisterSession reaches the wire (session ends in RST).
- CaptureFileTests also check the CPF walk ends at the frame end and that every
  reply service is its request service | 0x80.
2026-10-07 15:58:05 -04:00

135 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Golden frame captures — libplctag ⇄ ab_server
These `.hex` files are the exact EtherNet/IP frames libplctag exchanged with the `ab_server`
simulator. The native AB transport's codecs (plan Tasks 7 and 8) assert **byte parity** against
them.
> ⚠️ They prove parity with **libplctag**, not correctness against a controller. The independent
> reference is the real-press captures in
> `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs`
> and `CipTemplateObjectWireCaptureTests.cs`.
## Format
One frame per line: `> ` = client→server, `< ` = server→client, then the full encapsulation
frame (24-byte header + data) as uppercase hex with no spaces. `#` lines are comments; each file
opens with a comment block naming its client, server, profile, tag string and what the frames are.
`CaptureFile.Load` reads them; `CaptureFileTests` checks every frame's header length field
against its data length and that requests and replies alternate.
Values are **little-endian on the wire** and carry live per-session values — session handle,
connection IDs, connection serial, PCCC TNS. A parity test must compare structure and the
fields it builds, not those session-assigned values.
### Simulator behaviour that is NOT controller behaviour
- **ab_server zeroes the reply sender context.** libplctag sends a non-zero 8-byte sender context
on every SendRRData request (e.g. `45A0091C00000000` in `logix-forward-open.hex`), and every
ab_server reply carries `0000000000000000`. A real target echoes the request's context.
Consequences: parity tests must **exclude** the sender-context field (header bytes 12–19), and
the native transport **must not depend on the echo** to correlate replies, or it will pass
against a real controller and fail against the sim, or the reverse.
- **ab_server types a STRING as atomic `D0 00`, not as a structure.** A STRING read reply (see
`logix-read-string.hex`) is `D2 00 00 00` + type `D0 00` + 88 data bytes: a 4-byte `LEN`
(`07 00 00 00` for `"HelloAB"`), the 82-byte `DATA` field, and 2 pad bytes. Write requests
(`logix-write-string.hex`) echo the same `D0 00` type. A real Logix STRING is a structure:
type `A0 02` followed by the 2-byte structure handle, then the same 88-byte body. Tasks 7 and 8
should treat the `D0 00` type as simulator behaviour. Byte parity on the type field holds only
against this sim, and a STRING codec must accept the `A0 02` + handle form from a controller.
- **ab_server refuses libplctag's BOOL write.** libplctag sends two data bytes (`01 00`) for a
one-byte BOOL, and ab_server's "too much data" check (`cip.c` in `handle_write_request`) answers
CIP 0x03. That is why there is no BOOL write fixture. `logix-read-bool.hex` was seeded by a raw
UCMM Write Tag carrying one data byte (`client seed-bool`, below). Incidentally, that write shows
ab_server accepts an Unconnected Send (0x52) routed `01 00` in ControlLogix mode.
## How they were taken
| | |
|---|---|
| Date | 2026-10-07 |
| Repo commit | `a0897fda` (fixture sources identical to the files on the docker host, checked by md5) |
| Client | **libplctag 1.5.2** (libplctag.NET NuGet, `Directory.Packages.props`) → libplctag.NativeImport 1.0.41 → native core **2.6.0** (read via `plc_tag_get_int_attribute(0,"version_*")`) |
| Server image | **`otopcua-ab-server:libplctag-release`** (ab_server from libplctag `v2.7.0` + vendored patches 0001 `@tags` listing and 0002 tag-not-found → CIP 0x04; both confirmed live — see `logix-tags-listing.hex` / `logix-read-unknown-tag.hex`) |
| AbCip compose | `/opt/otopcua-abcip` on `10.100.0.35` (= `tests/.../AbCip.IntegrationTests/Docker/`), host `:44818` |
| AbLegacy compose | `~/otopcua-ablegacy` on `10.100.0.35` (= `tests/.../AbLegacy.IntegrationTests/Docker/`), host `:44819` slc500 / `:44820` micrologix / `:44821` plc5 |
| Capture point | `tcpdump -i eth0 tcp port 44818` in a `nicolaka/netshoot:v0.13` sidecar sharing the simulator container's network namespace; `tshark` 4.2.4 in the same image; segments re-joined into frames by the 24-byte header length field |
| Recipe | `scripts/ab-capture/capture.sh run <container> <name> -- scripts/ab-capture/capture.sh client …`, then `capture.sh tohex …` |
| Client | `scripts/ab-capture/client/` (`ab-capture-client.csproj`). Not in the solution; pins libplctag 1.5.2 with CPM off. `capture.sh client …` builds it once and runs it |
The client (`scripts/ab-capture/client/Program.cs`) is a small console program over libplctag 1.5.2 that mirrors how
`LibplctagTagRuntime` / `LibplctagLegacyTagRuntime` / `LibplctagTagEnumerator` build a tag
(`Gateway`, `Path`, `PlcType`, `Protocol=ab_eip`, `Name`, `Timeout=5s`, `ElementCount` when > 1;
no other attributes), then runs `InitializeAsync` + one `ReadAsync` or `WriteAsync`, disposes the
tag and calls `plc_tag_shutdown` so the Forward Close reaches the wire. **One fresh session per
capture**, one profile up at a time. Note that `InitializeAsync` itself performs a read, so a
"read" capture holds two read exchanges and a "write" capture holds a read then the write.
Values were seeded before the reads so that no decode oracle is all zeros. Seeding commands
(they were not captured):
```bash
C=scripts/ab-capture/capture.sh
$C client write 10.100.0.35:44818 1,0 ControlLogix TestDINT dint 1 123456
$C client write 10.100.0.35:44818 1,0 ControlLogix TestString string 1 HelloAB
$C client write 10.100.0.35:44818 1,0 ControlLogix TestArray dintseq 16 x # element i (0-based) = ((i+1)<<24)|(i+1)
$C client seed-bool 10.100.0.35 44818 TestBOOL 1 # raw UCMM, see above
```
The `logix-write-*` captures are themselves the seeds for TestDINT and TestString. Each read
capture is then, for example:
```bash
CAPDIR=<fresh dir> $C run otopcua-ab-server-controllogix logix-read-dint-array16 -- \
$C client read 10.100.0.35:44818 1,0 ControlLogix TestArray raw 16
$C tohex <fresh dir>/logix-read-dint-array16.pcap logix-read-dint-array16.full.hex
```
### Per-file record
All files: libplctag 1.5.2 (native 2.6.0), image `otopcua-ab-server:libplctag-release`. The
tag string is the libplctag attribute set the wrapper was given.
| File | Profile | Tag string | Frames |
|---|---|---|---|
| `logix-register-session.hex` | controllogix | `protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT` | RegisterSession (frames 1–2 of the `logix-read-dint` session) |
| `logix-forward-open.hex` | controllogix | same as above | Large Forward Open **0x5B** (frames 3–4) |
| `logix-read-dint.hex` | controllogix | `…&plc=controllogix&name=TestDINT` | 2× connected Read Tag Fragmented 0x52 (value 123456) |
| `logix-read-dint-array16.hex` | controllogix | `…&name=TestArray&elem_count=16` | 2× 0x52, 16 elements; element i (0-based) = `((i+1)<<24)\|(i+1)`, i.e. `0x01000001` … `0x10000010` (LE on the wire: `01 00 00 01`, `02 00 00 02` …) |
| `logix-read-string.hex` | controllogix | `…&name=TestString` | 2× 0x52, STRING `"HelloAB"` |
| `logix-read-bool.hex` | controllogix | `…&name=TestBOOL` | 2× 0x52, BOOL **1** (seeded via `client seed-bool`) |
| `logix-write-dint.hex` | controllogix | `…&name=TestDINT` | 0x52 read, then Write Tag 0x4D of 123456 |
| `logix-write-string.hex` | controllogix | `…&name=TestString` | 0x52 read, then 0x4D of `"HelloAB"` |
| `logix-read-unknown-tag.hex` | controllogix | `…&name=NoSuchTag` | 0x52 → general status **0x04** (patch 0002); libplctag raised `ErrorNotFound`, no retry |
| `logix-tags-listing.hex` | controllogix | `…&name=@tags` | Get Instance Attribute List **0x55** on Symbol class 0x6B (patch 0001); all 9 tags in one reply, status 0x00 |
| `logix-forward-close.hex` | controllogix | `…&name=TestDINT` | Forward Close 0x4E (last 2 frames of the `logix-read-dint` session) |
| `micro800-read-dint.hex` | micro800 | `protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT` | **Full session**: RegisterSession, Large FO, 2× 0x52 (value 987654), Forward Close |
| `slc500-read-n7.hex` | slc500 | `protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0` | Full session: RegisterSession, 2× Execute PCCC 0x4B / PCCC 0x0F-0xA2 (value 4321) |
| `slc500-write-n7.hex` | slc500 | same | Full session: 0x0F-0xA2 read, then 0x0F-0xAA write of 4321 |
| `slc500-read-f8.hex` | slc500 | `…:44819&path=1,0&plc=slc500&name=F8:0` | Full session: 2× 0x0F-0xA2 (value 3.5) |
| `micrologix-read-n7.hex` | micrologix | `protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0` | Full session: 2× 0x0F-0xA2 (value 2468) |
| `plc5-read-n7.hex` | plc5 | `protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0` | Full session: 2× PCCC 0x0F-**0x01** PLC-5 typed read (value 1357) |
Logix files other than `register-session`/`forward-open`/`forward-close` hold **only the
operation's exchanges**; the handshake frames of their sessions are byte-for-byte the same shape
as those three files. The Micro800 and PCCC files hold the **whole session** so their routing is
visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.
## Routing observed
What libplctag 2.6.0 actually put on the wire. Tasks 9, 12 and 14 take their `Routing` choice
from this table.
| Family | ForwardOpen | CIP request wrapped in Unconnected Send (0x52) | Route bytes | Requestor-id bytes (PCCC) |
|---|---|---|---|---|
| controllogix (`path=1,0`) | **yes** — Large FO 0x5B only (0x54 never sent; the 0x5B succeeded). FO itself is sent direct to the Connection Manager `20 06 24 01`, not wrapped. Connection path `01 00 20 02 24 01` (3 words), net params `0x42000FA2`, RPI 1 000 000 µs, transport `0xA3` | **no** — requests ride the connection (SendUnitData, item 0xB1) | `01 00` (port 1, slot 0), carried only inside the FO connection path | n/a |
| micro800 (no path) | **yes** — Large FO 0x5B, connection path `20 02 24 01` (2 words, no port segment). ⚠️ Not UCMM; see concerns | **no** — connected | none | n/a |
| slc500 (`path=1,0`) | **no** | **no** — Execute PCCC 0x4B sent unconnected (SendRRData, item 0xB2) straight to `20 67 24 01` (PCCC object) | **none — the `1,0` path was given but never appeared on the wire** | `07 3D F3 45 43 50 21` (length 7, vendor 0xF33D, serial 0x21504345) |
| micrologix (no path) | **no** | **no** — as slc500 | none | `07 3D F3 45 43 50 21` |
| plc5 (`path=1,0`) | **no** | **no** — as slc500 | **none — `1,0` not emitted** | `07 3D F3 45 43 50 21` |
| logixpccc (`path=1,0`, against the controllogix profile) | **no** | **yes** — Unconnected Send 0x52 to `20 06 24 01`, priority/tick `0A 05`, embedded 0x4B (len 0x1C) | `01 00` (path size 1 word, reserved, then `01 00`) | `07 3D F3 45 43 50 21` |
**logixpccc is not reachable**: ab_server's ControlLogix mode has no PCCC object and answers
`CB 00 08 00` (service not supported). Its request shape above is read from the client side of
that rejected exchange; **no `.hex` fixture was committed for it**, since a parity fixture whose
reply is an error would teach nothing about the success path.