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:
Joseph Doherty
2026-10-07 15:58:05 -04:00
parent 1201d5fcfb
commit b085360454
7 changed files with 293 additions and 21 deletions
+18 -6
View File
@@ -20,12 +20,17 @@
# 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...>
#
# 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
+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>