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.
This commit is contained in:
+30
@@ -50,6 +50,36 @@ public sealed class CaptureFileTests
|
||||
for (var i = 0; i < frames.Count; i++) frames[i].FromClient.ShouldBe(i % 2 == 0, $"{name} frame {i}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_CPF_item_walk_ends_exactly_at_the_frame_end(string name)
|
||||
{
|
||||
foreach (var f in CaptureFile.Load(name).Where(f => BitConverter.ToUInt16(f.Bytes, 0) is 0x6F or 0x70))
|
||||
{
|
||||
var pos = 24 + 4 + 2;
|
||||
var itemCount = BitConverter.ToUInt16(f.Bytes, pos);
|
||||
pos += 2;
|
||||
for (var i = 0; i < itemCount; i++) pos += 4 + BitConverter.ToUInt16(f.Bytes, pos + 2);
|
||||
|
||||
pos.ShouldBe(f.Bytes.Length, name);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_reply_service_is_its_request_service_with_the_reply_bit(string name)
|
||||
{
|
||||
var frames = CaptureFile.Load(name);
|
||||
for (var i = 0; i + 1 < frames.Count; i += 2)
|
||||
{
|
||||
var request = CaptureFile.CipPayload(frames[i]);
|
||||
var reply = CaptureFile.CipPayload(frames[i + 1]);
|
||||
if (request.Length == 0) continue; // RegisterSession: no CIP
|
||||
|
||||
reply[0].ShouldBe((byte)(request[0] | 0x80), $"{name} pair {i / 2}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipPayload_strips_the_connected_sequence_count()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,27 @@ Values are **little-endian on the wire** and carry live per-session values — s
|
||||
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
|
||||
|
||||
| | |
|
||||
@@ -32,9 +53,10 @@ fields it builds, not those session-assigned values.
|
||||
| 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 …` then `capture.sh tohex …` |
|
||||
| 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 was a ~60-line console program over libplctag 1.5.2 that mirrors how
|
||||
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
|
||||
@@ -42,6 +64,26 @@ tag and calls `plc_tag_shutdown` so the Forward Close reaches the wire. **One fr
|
||||
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
|
||||
@@ -52,9 +94,9 @@ tag string is the libplctag attribute set the wrapper was given.
|
||||
| `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 |
|
||||
| `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 0 |
|
||||
| `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 |
|
||||
@@ -70,7 +112,7 @@ tag string is the libplctag attribute set the wrapper was given.
|
||||
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).
|
||||
visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.
|
||||
|
||||
## Routing observed
|
||||
|
||||
|
||||
+5
-5
@@ -4,8 +4,8 @@
|
||||
# profile: controllogix
|
||||
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestBOOL
|
||||
# session: logix-read-bool (captured 2026-10-07, scripts/ab-capture/capture.sh)
|
||||
# frames : Two connected 0x52 exchanges for a BOOL (Initialize read + explicit Read). TestBOOL held 0.
|
||||
> 7000280074EA6678000000000000000000000000000000000000000001000200A1000400ABDA0000B100140001005205910854657374424F4F4C010000000000
|
||||
< 70001D0074EA6678000000000000000000000000000000000000000001000200A100040039377043B10009000100D2000000C10000
|
||||
> 7000280074EA6678000000000000000000000000000000000000000001000200A1000400ABDA0000B100140002005205910854657374424F4F4C010000000000
|
||||
< 70001D0074EA6678000000000000000000000000000000000000000001000200A100040039377043B10009000200D2000000C10000
|
||||
# frames : Two connected 0x52 exchanges for a BOOL (Initialize read + explicit Read). TestBOOL held 1, seeded by a raw UCMM Write Tag (client seed-bool) because ab_server rejects libplctag's own BOOL write with CIP 0x03.
|
||||
> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140001005205910854657374424F4F4C010000000000
|
||||
< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000100D2000000C10001
|
||||
> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140002005205910854657374424F4F4C010000000000
|
||||
< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000200D2000000C10001
|
||||
|
||||
+5
-5
@@ -4,8 +4,8 @@
|
||||
# profile: controllogix
|
||||
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestArray&elem_count=16
|
||||
# session: logix-read-dint-array16 (captured 2026-10-07, scripts/ab-capture/capture.sh)
|
||||
# frames : Two connected 0x52 exchanges for 16 DINT elements (Initialize read + explicit Read).
|
||||
> 70002A00C222CFC1000000000000000000000000000000000000000001000200A1000400B0B13F6BB100160001005206910954657374417272617900100000000000
|
||||
< 70005C00C222CFC1000000000000000000000000000000000000000001000200A1000400E59A1D32B10048000100D2000000C40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
> 70002A00C222CFC1000000000000000000000000000000000000000001000200A1000400B0B13F6BB100160002005206910954657374417272617900100000000000
|
||||
< 70005C00C222CFC1000000000000000000000000000000000000000001000200A1000400E59A1D32B10048000200D2000000C40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
# frames : Two connected 0x52 exchanges for 16 DINT elements (Initialize read + explicit Read). Seeded first (client write ... dintseq): element i (0-based) = ((i+1) << 24) | (i+1), i.e. 0x01000001, 0x02000002 ... 0x10000010.
|
||||
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160001005206910954657374417272617900100000000000
|
||||
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000100D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010
|
||||
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160002005206910954657374417272617900100000000000
|
||||
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000200D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010
|
||||
|
||||
Reference in New Issue
Block a user