From b085360454075f309f3c1e59b307cad58244cedf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 15:58:05 -0400 Subject: [PATCH] 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. --- scripts/ab-capture/capture.sh | 24 ++- scripts/ab-capture/client/Program.cs | 169 ++++++++++++++++++ .../client/ab-capture-client.csproj | 19 ++ .../Captures/CaptureFileTests.cs | 30 ++++ .../Captures/README.md | 52 +++++- .../Captures/logix-read-bool.hex | 10 +- .../Captures/logix-read-dint-array16.hex | 10 +- 7 files changed, 293 insertions(+), 21 deletions(-) create mode 100644 scripts/ab-capture/client/Program.cs create mode 100644 scripts/ab-capture/client/ab-capture-client.csproj diff --git a/scripts/ab-capture/capture.sh b/scripts/ab-capture/capture.sh index b1fb4f7c..31e185d5 100755 --- a/scripts/ab-capture/capture.sh +++ b/scripts/ab-capture/capture.sh @@ -20,12 +20,17 @@ # Usage: # CAPDIR= scripts/ab-capture/capture.sh run -- # scripts/ab-capture/capture.sh tohex +# scripts/ab-capture/capture.sh client # -# The client command is whatever drives libplctag; the session used for the -# committed fixtures is a ~60-line console program over libplctag 1.5.2 (one tag, -# one Initialize + Read/Write, then Dispose + plc_tag_shutdown so the Forward Close -# and Unregister Session reach the wire). See Captures/README.md for every -# invocation. +# `client` builds (once) and runs scripts/ab-capture/client — the console program +# over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize + +# Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire. +# No UnregisterSession is ever sent; the session ends with a TCP RST. Example: +# +# CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \ +# scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint +# +# See Captures/README.md for every invocation used. # # Fixtures (one AbCip profile at a time — they all bind host :44818): # ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d' @@ -39,6 +44,13 @@ REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}" cmd="${1:-}"; shift || true case "$cmd" in + client) + here="$(cd "$(dirname "$0")" && pwd)" + dll="$here/client/bin/Release/net10.0/ab-capture-client.dll" + [[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2 + exec dotnet "$dll" "$@" + ;; + run) container="$1"; name="$2"; shift 2 [[ "${1:-}" == "--" ]] && shift @@ -92,5 +104,5 @@ for k, rest in bufs.items(): ;; *) - sed -n '2,32p' "$0"; exit 2 ;; + sed -n '2,37p' "$0"; exit 2 ;; esac diff --git a/scripts/ab-capture/client/Program.cs b/scripts/ab-capture/client/Program.cs new file mode 100644 index 00000000..c8a74539 --- /dev/null +++ b/scripts/ab-capture/client/Program.cs @@ -0,0 +1,169 @@ +// Capture client for scripts/ab-capture/capture.sh. +// +// One libplctag tag, one operation, then Dispose + plc_tag_shutdown so the Forward Close +// reaches the wire. The tag is built with exactly the properties the drivers set +// (LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator): Gateway, Path, +// PlcType, Protocol=ab_eip, Name, Timeout, and ElementCount when > 1. Nothing else. +// +// usage: +// ab-capture-client version +// ab-capture-client read [count] +// ab-capture-client write +// +// a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc +// dint | int | real | string | bool | raw (read only: hex dump of the buffer) +// dintseq (write only): element i (0-based) = ((i+1) << 24) | (i+1), for elements; +// is ignored but required. +// env UNCONNECTED=1 sets UseConnectedMessaging=false (ignored by libplctag 2.6.0 for Micro800). +using libplctag; +using libplctag.NativeImport; + +if (args.Length == 0) +{ + Console.Error.WriteLine("usage: see the header of Program.cs"); + return 2; +} + +if (args[0] == "seed-bool") +{ + // ab_server rejects libplctag's BOOL write with CIP 0x03: libplctag sends two data bytes for a + // one-byte BOOL and ab_server's "too much data" check refuses it. This seeds a BOOL for a + // later libplctag READ capture with one raw UCMM Write Tag carrying exactly one data byte. + // usage: seed-bool <0|1> (route is port 1 / slot 0) + return await SeedBoolAsync(args[1], int.Parse(args[2]), args[3], args[4] == "1" ? (byte)1 : (byte)0); +} + +if (args[0] == "version") +{ + Console.WriteLine($"{plctag.plc_tag_get_int_attribute(0, "version_major", -1)}." + + $"{plctag.plc_tag_get_int_attribute(0, "version_minor", -1)}." + + $"{plctag.plc_tag_get_int_attribute(0, "version_patch", -1)}"); + return 0; +} + +var op = args[0]; +var gateway = args[1]; +var path = args[2] == "-" ? null : args[2]; +var plc = Enum.Parse(args[3], ignoreCase: true); +var name = args[4]; +var type = args[5]; +var count = args.Length > 6 ? int.Parse(args[6]) : 1; + +var tag = new Tag +{ + Gateway = gateway, + Path = path, + PlcType = plc, + Protocol = Protocol.ab_eip, + Name = name, + Timeout = TimeSpan.FromSeconds(5), +}; +if (count > 1) tag.ElementCount = count; +if (Environment.GetEnvironmentVariable("UNCONNECTED") == "1") tag.UseConnectedMessaging = false; + +var exit = 0; +try +{ + await tag.InitializeAsync(); + if (op == "write") + { + var value = args[7]; + switch (type) + { + case "dint": tag.SetInt32(0, int.Parse(value)); break; + case "int": tag.SetInt16(0, short.Parse(value)); break; + case "real": tag.SetFloat32(0, float.Parse(value)); break; + case "string": tag.SetString(0, value); break; + // As LibplctagTagRuntime does: a whole-byte write (SetBit on a Logix BOOL is ErrorBadParam). + case "bool": tag.SetInt8(0, value is "1" or "true" ? (sbyte)1 : (sbyte)0); break; + case "dintseq": + for (var i = 0; i < count; i++) tag.SetInt32(i * 4, ((i + 1) << 24) | (i + 1)); + break; + default: throw new ArgumentException($"unknown write type {type}"); + } + await tag.WriteAsync(); + Console.WriteLine("write ok"); + } + else + { + await tag.ReadAsync(); + Console.WriteLine($"read ok size={tag.GetSize()}"); + Console.WriteLine(type switch + { + "dint" => tag.GetInt32(0).ToString(), + "int" => tag.GetInt16(0).ToString(), + "real" => tag.GetFloat32(0).ToString(), + "string" => tag.GetString(0), + "bool" => tag.GetBit(0).ToString(), + _ => Convert.ToHexString(tag.GetBuffer()), + }); + } +} +catch (Exception ex) +{ + Console.WriteLine($"EXC {ex.GetType().Name}: {ex.Message}"); + exit = 1; +} +finally +{ + tag.Dispose(); +} + +plctag.plc_tag_shutdown(); +await Task.Delay(500); // let the Forward Close flush before the process exits +return exit; + +static async Task SeedBoolAsync(string host, int port, string tagName, byte value) +{ + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync(host, port); + var s = tcp.GetStream(); + + // RegisterSession + var reg = new byte[28]; + reg[0] = 0x65; reg[2] = 4; reg[24] = 1; + await s.WriteAsync(reg); + var regReply = await ReadFrameAsync(s); + var session = BitConverter.ToUInt32(regReply, 4); + + // Write Tag (0x4D): ANSI symbol segment, type BOOL 0xC1, count 1, ONE data byte. + var name = System.Text.Encoding.ASCII.GetBytes(tagName); + var sym = new List { 0x91, (byte)name.Length }; + sym.AddRange(name); + if (sym.Count % 2 == 1) sym.Add(0); + var write = new List { 0x4D, (byte)(sym.Count / 2) }; + write.AddRange(sym); + write.AddRange([0xC1, 0x00, 0x01, 0x00, value]); + + // Unconnected Send (0x52) to the Connection Manager, routed to port 1 / slot 0. + var ucmm = new List { 0x52, 0x02, 0x20, 0x06, 0x24, 0x01, 0x0A, 0x05 }; + ucmm.AddRange(BitConverter.GetBytes((ushort)write.Count)); + ucmm.AddRange(write); + if (write.Count % 2 == 1) ucmm.Add(0); + ucmm.AddRange([0x01, 0x00, 0x01, 0x00]); + + // SendRRData: interface handle, timeout, 2 items (null address, unconnected data). + var data = new List { 0, 0, 0, 0, 0x0A, 0, 2, 0, 0, 0, 0, 0, 0xB2, 0 }; + data.AddRange(BitConverter.GetBytes((ushort)ucmm.Count)); + data.AddRange(ucmm); + var frame = new byte[24 + data.Count]; + frame[0] = 0x6F; + BitConverter.GetBytes((ushort)data.Count).CopyTo(frame, 2); + BitConverter.GetBytes(session).CopyTo(frame, 4); + data.CopyTo(frame, 24); + await s.WriteAsync(frame); + + var reply = await ReadFrameAsync(s); + var cip = reply.AsSpan(24 + 16).ToArray(); // after handle, timeout, count, null item, data item header + Console.WriteLine($"seed-bool reply CIP: {Convert.ToHexString(cip)}"); + return cip.Length >= 3 && cip[0] == 0xCD && cip[2] == 0 ? 0 : 1; +} + +static async Task ReadFrameAsync(System.Net.Sockets.NetworkStream s) +{ + var header = new byte[24]; + await s.ReadExactlyAsync(header); + var body = new byte[BitConverter.ToUInt16(header, 2)]; + await s.ReadExactlyAsync(body); + return [.. header, .. body]; +} diff --git a/scripts/ab-capture/client/ab-capture-client.csproj b/scripts/ab-capture/client/ab-capture-client.csproj new file mode 100644 index 00000000..8d5476ad --- /dev/null +++ b/scripts/ab-capture/client/ab-capture-client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs index 08382911..eab2d89e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs @@ -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() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md index 74ae2eb8..d8922ddb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md @@ -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 -- 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= $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 /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 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex index bb6dca21..c85f4a4e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex @@ -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 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex index 7b57c3ab..a5efb005 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex @@ -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