merge: Task 4 — golden libplctag<->ab_server frame captures

This commit is contained in:
Joseph Doherty
2026-10-07 15:59:39 -04:00
24 changed files with 779 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Golden-frame capture recipe for the native AB transport (Task 4 of
# docs/plans/2026-10-07-native-ab-transport.md).
#
# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
# converts each pcap into the `.hex` line format consumed by
# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
#
# > <client->server EIP frame, uppercase hex, no spaces>
# < <server->client EIP frame>
# # comment
#
# Packets are captured INSIDE the simulator container's network namespace
# (netshoot sidecar), where ab_server always listens on 44818 — so the same
# filter works for the AbLegacy containers whose host ports are 44819-44821.
# tshark also runs in the netshoot image on the docker host, so nothing needs
# installing on the workstation beyond python3.
#
# Usage:
# CAPDIR=<fresh empty dir> scripts/ab-capture/capture.sh run <container> <name> -- <client command...>
# scripts/ab-capture/capture.sh tohex <pcap> <out.hex>
# scripts/ab-capture/capture.sh client <client args...>
#
# `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'
# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
set -euo pipefail
DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.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
: "${CAPDIR:?set CAPDIR to a fresh empty directory}"
sidecar="abcap-$name"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
sleep 2 # let tcpdump open the interface before the client connects
"$@" || echo "client exited non-zero: $?" >&2
sleep 1
ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
echo "$CAPDIR/$name.pcap"
;;
tohex)
pcap="$1"; out="$2"
base="$(basename "$pcap")"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
# srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
-Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
| python3 -I -c '
import sys
# Re-join TCP segments into EIP encapsulation frames per direction, using the
# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
bufs = {True: b"", False: b""}
for line in sys.stdin:
line = line.strip()
if not line:
continue
port, payload = line.split("\t")
from_client = port != "44818" # srcport 44818 => server->client
bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
b = bufs[from_client]
while len(b) >= 24:
total = 24 + int.from_bytes(b[2:4], "little")
if len(b) < total:
break
print(("> " if from_client else "< ") + b[:total].hex().upper())
b = b[total:]
bufs[from_client] = b
for k, rest in bufs.items():
if rest:
sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
' > "$out"
echo "$out: $(grep -c '^[<>]' "$out") frames"
;;
*)
sed -n '2,37p' "$0"; exit 2 ;;
esac
+169
View File
@@ -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 <host:port> <path|-> <plc> <name> <type> [count]
// ab-capture-client write <host:port> <path|-> <plc> <name> <type> <count> <value>
//
// <plc> a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc
// <type> 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 <count> elements;
// <value> 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 <host> <port> <tag> <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<PlcType>(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<int> 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<byte> { 0x91, (byte)name.Length };
sym.AddRange(name);
if (sym.Count % 2 == 1) sym.Add(0);
var write = new List<byte> { 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<byte> { 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<byte> { 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<byte[]> 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];
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Capture client for scripts/ab-capture/capture.sh — drives libplctag 1.5.2 against the ab_server
fixtures so the golden frames in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures
can be re-taken. Deliberately NOT in ZB.MOM.WW.OtOpcUa.slnx. Pinned to the exact libplctag the
drivers use; CPM is switched off so the pin lives here and cannot drift with Directory.Packages.props.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="libplctag" Version="1.5.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
/// <summary>One EtherNet/IP encapsulation frame from a golden capture.</summary>
/// <param name="FromClient"><c>true</c> for a client→server (<c>&gt;</c>) frame.</param>
/// <param name="Bytes">The full frame: 24-byte encapsulation header plus data.</param>
internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);
/// <summary>
/// Loads the golden libplctag ⇄ ab_server captures in <c>Captures/*.hex</c>. See
/// <c>Captures/README.md</c> for how each was taken.
/// </summary>
internal static class CaptureFile
{
/// <summary>The directory the <c>.hex</c> fixtures are copied to.</summary>
public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");
/// <summary>Loads every frame of a capture, in wire order; <c>#</c> lines are skipped.</summary>
/// <param name="name">The capture's file name, e.g. <c>logix-read-dint.hex</c>.</param>
/// <returns>The frames.</returns>
public static IReadOnlyList<CapturedFrame> Load(string name) =>
File.ReadAllLines(Path.Combine(Dir, name))
.Where(l => l.Length > 2 && l[0] is '>' or '<')
.Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
.ToList();
/// <summary>The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
/// minus the 2-byte sequence count for connected data).</summary>
/// <param name="f">The frame.</param>
/// <returns>The CIP payload, or empty for any other encapsulation command.</returns>
public static byte[] CipPayload(CapturedFrame f)
{
var b = f.Bytes;
var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
if (cmd is not (0x6F or 0x70)) return []; // only SendRRData / SendUnitData carry CPF items
var pos = 24 + 4 + 2; // header + interface handle + timeout
var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
byte[] last = [];
for (var i = 0; i < itemCount; i++)
{
var type = BitConverter.ToUInt16(b, pos);
var len = BitConverter.ToUInt16(b, pos + 2);
last = b.AsSpan(pos + 4, len).ToArray();
if (type == 0x00B1) last = last[2..]; // strip connected sequence count
pos += 4 + len;
}
return last;
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
public sealed class CaptureFileTests
{
private static readonly string[] Required =
[
"logix-register-session.hex", "logix-forward-open.hex",
"logix-read-dint.hex", "logix-read-dint-array16.hex", "logix-read-string.hex", "logix-read-bool.hex",
"logix-write-dint.hex", "logix-write-string.hex", "logix-read-unknown-tag.hex",
"logix-tags-listing.hex", "logix-forward-close.hex", "micro800-read-dint.hex",
"slc500-read-n7.hex", "slc500-write-n7.hex", "slc500-read-f8.hex",
"micrologix-read-n7.hex", "plc5-read-n7.hex",
];
public static TheoryData<string> AllCaptures()
{
var data = new TheoryData<string>();
foreach (var f in Directory.GetFiles(CaptureFile.Dir, "*.hex").Order()) data.Add(Path.GetFileName(f));
return data;
}
[Fact]
public void Every_required_capture_is_present() =>
Required.Where(n => !File.Exists(Path.Combine(CaptureFile.Dir, n))).ShouldBeEmpty();
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_frame_length_field_matches_its_data_length(string name)
{
var frames = CaptureFile.Load(name);
frames.ShouldNotBeEmpty();
foreach (var f in frames)
{
f.Bytes.Length.ShouldBeGreaterThanOrEqualTo(24, name);
BitConverter.ToUInt16(f.Bytes, 2).ShouldBe((ushort)(f.Bytes.Length - 24), name);
}
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Frames_alternate_request_then_reply(string name)
{
var frames = CaptureFile.Load(name);
frames.Count.ShouldBe(frames.Count / 2 * 2, name);
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()
{
// Connected Read Tag Fragmented of TestDINT: service 0x52, path size 5 words, symbol 0x91 len 8.
var request = CaptureFile.Load("logix-read-dint.hex")[0];
CaptureFile.CipPayload(request)[..4].ShouldBe(new byte[] { 0x52, 0x05, 0x91, 0x08 });
}
[Fact]
public void CipPayload_returns_the_unconnected_item_for_SendRRData()
{
// Large Forward Open request goes out in an unconnected data item (0xB2): service 0x5B.
var request = CaptureFile.Load("logix-forward-open.hex")[0];
CaptureFile.CipPayload(request)[0].ShouldBe((byte)0x5B);
}
[Fact]
public void CipPayload_is_empty_for_RegisterSession() =>
CaptureFile.CipPayload(CaptureFile.Load("logix-register-session.hex")[0]).ShouldBeEmpty();
}
@@ -0,0 +1,134 @@
# 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.
@@ -0,0 +1,9 @@
# logix-forward-close.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Forward Close 0x4E request/reply (last two frames of the logix-read-dint session). No UnregisterSession followed.
> 6F002800F3A5081A00000000F0B223110000000000000000000000000100020000000000B20018004E02200624010A05B1723DF3454350210300010020022401
< 6F001E00F3A5081A00000000000000000000000000000000000000000100020000000000B2000E00CE000000B1723DF3454350210000
@@ -0,0 +1,9 @@
# logix-forward-open.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Large Forward Open 0x5B request/reply (frames 3-4 of the logix-read-dint session). libplctag never sent a 0x54 here: the Large FO succeeded, so no fallback occurred.
> 6F004400F3A5081A00000000EFB223110000000000000000000000000100020000000000B20034005B02200624010A0500000000609E3F07B1723DF3454350210100000040420F00A20F004240420F00A20F0042A303010020022401
< 6F002E00F3A5081A00000000000000000000000000000000000000000100020000000000B2001E00DB0000003F63DF2C609E3F07B1723DF34543502140420F0040420F000000
@@ -0,0 +1,11 @@
# logix-read-bool.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# 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 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
@@ -0,0 +1,11 @@
# logix-read-dint-array16.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# 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). Seeded first (client write ... dintseq): element i (0-based) = ((i+1) << 24) | (i+1), i.e. 0x01000001, 0x02000002 ... 0x10000010.
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160001005206910954657374417272617900100000000000
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000100D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010
> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160002005206910954657374417272617900100000000000
< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000200D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010
@@ -0,0 +1,11 @@
# logix-read-dint.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Two connected Read Tag Fragmented (0x52) exchanges: the read Initialize performs, then the explicit Read. TestDINT held 123456.
> 70002800F3A5081A000000000000000000000000000000000000000001000200A10004003F63DF2CB10014000100520591085465737444494E54010000000000
< 70002000F3A5081A000000000000000000000000000000000000000001000200A1000400609E3F07B1000C000100D2000000C40040E20100
> 70002800F3A5081A000000000000000000000000000000000000000001000200A10004003F63DF2CB10014000200520591085465737444494E54010000000000
< 70002000F3A5081A000000000000000000000000000000000000000001000200A1000400609E3F07B1000C000200D2000000C40040E20100
@@ -0,0 +1,11 @@
# logix-read-string.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestString
# session: logix-read-string (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Two connected 0x52 exchanges for a STRING (Initialize read + explicit Read). TestString held "HelloAB".
> 70002A00C782C518000000000000000000000000000000000000000001000200A10004003194EE20B100160001005206910A54657374537472696E67010000000000
< 70007400C782C518000000000000000000000000000000000000000001000200A1000400C02DDF0FB10060000100D2000000D0000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
> 70002A00C782C518000000000000000000000000000000000000000001000200A10004003194EE20B100160002005206910A54657374537472696E67010000000000
< 70007400C782C518000000000000000000000000000000000000000001000200A1000400C02DDF0FB10060000200D2000000D0000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
@@ -0,0 +1,9 @@
# logix-read-unknown-tag.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=NoSuchTag
# session: logix-read-unknown-tag (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Initialize read of a non-existent tag; ab_server (patch 0002) answers general status 0x04. libplctag raised ErrorNotFound and sent no second read.
> 70002A00ABA8A2FA000000000000000000000000000000000000000001000200A100040003AB672DB10016000100520691094E6F5375636854616700010000000000
< 70001A00ABA8A2FA000000000000000000000000000000000000000001000200A10004003CCC685DB10006000100D2000400
@@ -0,0 +1,9 @@
# logix-register-session.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : RegisterSession request/reply (frames 1-2 of the logix-read-dint session).
> 65000400000000000000000000000000000000000000000001000000
< 65000400F3A5081A0000000000000000000000000000000001000000
@@ -0,0 +1,9 @@
# logix-tags-listing.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=@tags
# session: logix-tags-listing (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : One Get Instance Attribute List (0x55) on the Symbol class 0x6B (served by patch 0001); the reply lists all 9 seeded tags in one transfer.
> 70002800FF0C6DCD000000000000000000000000000000000000000001000200A10004006CA8729AB100140001005503206B2500000004000200070008000100
< 70003301FF0C6DCD000000000000000000000000000000000000000001000200A10004004E31C665B1001F010100D500000001000000C120010001000000000000000000000009004D6F746F725F52756E02000000C42004000100000000000000000000000B004C696E65315F436F756E7403000000CA2004000100000000000000000000000C0050756D705F315F537065656404000000C4200400100000000000000000000000090054657374417272617905000000D02058000100000000000000000000000A0054657374537472696E6706000000C220010001000000000000000000000008005465737453494E5407000000C1200100010000000000000000000000080054657374424F4F4C08000000CA2004000100000000000000000000000800546573745245414C09000000C420040001000000000000000000000008005465737444494E54
@@ -0,0 +1,11 @@
# logix-write-dint.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-write-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Initialize read (0x52) then Write Tag (0x4D) of 123456.
> 70002800B517D7FE000000000000000000000000000000000000000001000200A10004000B9D929FB10014000100520591085465737444494E54010000000000
< 70002000B517D7FE000000000000000000000000000000000000000001000200A1000400390C6A4CB1000C000100D2000000C40000000000
> 70002A00B517D7FE000000000000000000000000000000000000000001000200A10004000B9D929FB100160002004D0591085465737444494E54C400010040E20100
< 70001A00B517D7FE000000000000000000000000000000000000000001000200A1000400390C6A4CB10006000200CD000000
@@ -0,0 +1,11 @@
# logix-write-string.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestString
# session: logix-write-string (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Initialize read (0x52) then Write Tag (0x4D) of the STRING "HelloAB".
> 70002A003D5D525D000000000000000000000000000000000000000001000200A10004008DE304F6B100160001005206910A54657374537472696E67010000000000
< 700074003D5D525D000000000000000000000000000000000000000001000200A1000400D01BC454B10060000100D2000000D00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
> 700080003D5D525D000000000000000000000000000000000000000001000200A10004008DE304F6B1006C0002004D06910A54657374537472696E67D00001000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
< 70001A003D5D525D000000000000000000000000000000000000000001000200A1000400D01BC454B10006000200CD000000
@@ -0,0 +1,17 @@
# micro800-read-dint.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: micro800
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT
# session: micro800-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session. NOTE: libplctag 2.6.0 does NOT use UCMM for Micro800 - it opens a Large Forward Open (connection path 20 02 24 01, no port segment) and reads connected. TestDINT held 987654.
> 65000400000000000000000000000000000000000000000001000000
< 65000400D4389A440000000000000000000000000000000001000000
> 6F004200D4389A4400000000034C674E0000000000000000000000000100020000000000B20032005B02200624010A0500000000D717A44145043DF3454350210100000040420F00A20F004240420F00A20F0042A30220022401
< 6F002E00D4389A4400000000000000000000000000000000000000000100020000000000B2001E00DB0000000E9EDB9ED717A44145043DF34543502140420F0040420F000000
> 70002800D4389A44000000000000000000000000000000000000000001000200A10004000E9EDB9EB10014000100520591085465737444494E54010000000000
< 70002000D4389A44000000000000000000000000000000000000000001000200A1000400D717A441B1000C000100D2000000C40006120F00
> 70002800D4389A44000000000000000000000000000000000000000001000200A10004000E9EDB9EB10014000200520591085465737444494E54010000000000
< 70002000D4389A44000000000000000000000000000000000000000001000200A1000400D717A441B1000C000200D2000000C40006120F00
> 6F002600D4389A4400000000044C674E0000000000000000000000000100020000000000B20016004E02200624010A0545043DF345435021020020022401
< 6F001E00D4389A4400000000000000000000000000000000000000000100020000000000B2000E00CE00000045043DF3454350210000
@@ -0,0 +1,13 @@
# micrologix-read-n7.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: micrologix (host :44820)
# tag : protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0
# session: micrologix-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session (no path). PCCC 0x0F/0xA2 read, twice (Initialize + Read). N7:0 held 2468.
> 65000400000000000000000000000000000000000000000001000000
< 65000400999FDCC20000000000000000000000000000000001000000
> 6F002700999FDCC2000000001F7690460000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F001D76A20207890000
< 6F002100999FDCC200000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F001D76A409
> 6F002700999FDCC200000000217690460000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F001F76A20207890000
< 6F002100999FDCC200000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F001F76A409
@@ -0,0 +1,13 @@
# plc5-read-n7.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: plc5 (host :44821)
# tag : protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0
# session: plc5-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session. PCCC 0x0F/0x01 PLC-5 typed read, twice (Initialize + Read). N7:0 held 1357.
> 65000400000000000000000000000000000000000000000001000000
< 650004008543D8A50000000000000000000000000000000001000000
> 6F002A008543D8A50000000075C4F9370000000000000000000000000100020000000000B2001A004B0220672401073DF3454350210F0073C4010000010006070002
< 6F0021008543D8A500000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F0073C44D05
> 6F002A008543D8A50000000077C4F9370000000000000000000000000100020000000000B2001A004B0220672401073DF3454350210F0075C4010000010006070002
< 6F0021008543D8A500000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F0075C44D05
@@ -0,0 +1,13 @@
# slc500-read-f8.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: slc500 (host :44819)
# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=F8:0
# session: slc500-read-f8 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session. PCCC 0x0F/0xA2 read of F8:0, twice (Initialize + Read). F8:0 held 3.5.
> 65000400000000000000000000000000000000000000000001000000
< 650004002E82A6A40000000000000000000000000000000001000000
> 6F0027002E82A6A400000000A1DFE9520000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009FDFA204088A0000
< 6F0023002E82A6A400000000000000000000000000000000000000000100020000000000B2001300CB000000073DF3454350214F009FDF00006040
> 6F0027002E82A6A400000000A3DFE9520000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F00A1DFA204088A0000
< 6F0023002E82A6A400000000000000000000000000000000000000000100020000000000B2001300CB000000073DF3454350214F00A1DF00006040
@@ -0,0 +1,13 @@
# slc500-read-n7.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: slc500 (host :44819)
# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0
# session: slc500-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session. Unconnected Execute PCCC (0x4B) to class 0x67 inst 1, PCCC 0x0F/0xA2 protected typed logical read, twice (Initialize + Read). N7:0 held 4321.
> 65000400000000000000000000000000000000000000000001000000
< 65000400D0410F270000000000000000000000000000000001000000
> 6F002700D0410F27000000009B87395A0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009987A20207890000
< 6F002100D0410F2700000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F009987E110
> 6F002700D0410F27000000009D87395A0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009B87A20207890000
< 6F002100D0410F2700000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F009B87E110
@@ -0,0 +1,13 @@
# slc500-write-n7.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: slc500 (host :44819)
# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0
# session: slc500-write-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : FULL session. Initialize read (PCCC 0x0F/0xA2) then write (PCCC 0x0F/0xAA) of 4321.
> 65000400000000000000000000000000000000000000000001000000
< 65000400E65307EA0000000000000000000000000000000001000000
> 6F002700E65307EA00000000E7A3957B0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F00E5A3A20207890000
< 6F002100E65307EA00000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F00E5A30000
> 6F002900E65307EA00000000E9A3957B0000000000000000000000000100020000000000B20019004B0220672401073DF3454350210F00E7A3AA0207890000E110
< 6F001F00E65307EA00000000000000000000000000000000000000000100020000000000B2000F00CB000000073DF3454350214F00E7A3
@@ -19,6 +19,10 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="Captures\*.hex" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
</ItemGroup>