feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs

This commit is contained in:
Joseph Doherty
2026-10-07 16:03:41 -04:00
parent ec1a39ff6e
commit 5f86b370f7
10 changed files with 644 additions and 0 deletions
@@ -0,0 +1,72 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Parameters for a (Large) Forward Open. <see cref="ConnectionSize"/> is the maximum CIP
/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).</summary>
public sealed record ForwardOpenParams(
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
public static class ConnectionManagerServices
{
/// <summary>Priority/time-tick byte.</summary>
public const byte PriorityTick = 0x0A;
/// <summary>Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
public const byte TimeoutTicks = 0x05;
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
{
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
if ((embedded.Length & 1) == 1) w.U8(0);
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
}
public static byte[] ForwardOpen(ForwardOpenParams p)
{
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks)
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
WriteSide(w, p);
WriteSide(w, p);
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
}
private static void WriteSide(CipWriter w, ForwardOpenParams p)
{
w.U32(p.RpiMicros);
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
}
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
$"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
}
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
r.GeneralStatus == 0x08
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
{
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
}
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Multiple Service Packet (service 0x0A on the Message Router).</summary>
public static class MultipleServicePacket
{
/// <summary>Service + path size + Message Router path.</summary>
private const int HeaderSize = 6;
public static byte[] Build(IReadOnlyList<byte[]> requests)
{
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
var offset = 2 + 2 * requests.Count; // measured from the start of the count field
foreach (var r in requests) { w.U16((ushort)offset); offset += r.Length; }
foreach (var r in requests) w.Bytes(r);
return w.ToArray();
}
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
public static int BuildSize(IReadOnlyList<int> requestSizes)
{
var total = HeaderSize + 2 + 2 * requestSizes.Count;
foreach (var s in requestSizes) total += s;
return total;
}
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
{
var data = reply.Data;
var r = new CipReader(data.Span);
var n = r.U16();
if (n != count)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries {n} replies, expected {count}.");
var offsets = new int[n];
for (var i = 0; i < n; i++) offsets[i] = r.U16();
var result = new CipReply[n];
for (var i = 0; i < n; i++)
{
var end = i + 1 < n ? offsets[i + 1] : data.Length;
if (offsets[i] < 2 + 2 * n || end < offsets[i] || end > data.Length)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply offset {offsets[i]}..{end} is out of range.");
result[i] = CipReply.Parse(data[offsets[i]..end]);
}
return result;
}
}
@@ -0,0 +1,37 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser.</summary>
public static class LogixServices
{
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
Start(0x52, path).U16(elements).U32(offset).ToArray();
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
{
var w = Start(0x4D, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).Bytes(data).ToArray();
}
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
{
var w = Start(0x53, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).U32(offset).Bytes(data).ToArray();
}
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
{
var r = new CipReader(reply.Data.Span);
var type = r.U16();
ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
return (type, handle, reply.Data[r.Position..]);
}
}
@@ -0,0 +1,44 @@
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Symbol Object (class 0x6B) instance listing — the <c>@tags</c> enumeration.</summary>
public static class SymbolObjectServices
{
/// <summary>Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
/// to what libplctag sends (<c>logix-tags-listing.hex</c>).</summary>
public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
{
var path = new CipWriter();
if (!string.IsNullOrEmpty(programName))
{
var a = Encoding.ASCII.GetBytes(programName.StartsWith("Program:", StringComparison.Ordinal) ? programName : "Program:" + programName);
path.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
}
path.U8(0x20).U8(0x6B).U8(0x25).U8(0).U16((ushort)startInstance);
var p = path.ToArray();
return new CipWriter().U8(0x55).U8((byte)(p.Length / 2)).Bytes(p)
.U16(4).U16(2).U16(7).U16(8).U16(1).ToArray();
}
/// <summary>Entries are <c>u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name</c>.
/// <c>LastInstance</c> is the last entry's instance id (next start = LastInstance + 1).
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
public static (byte[] Entries, uint LastInstance, bool More) ParseListingReply(CipReply reply)
{
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
var span = reply.Data.Span;
var r = new CipReader(span);
uint last = 0;
while (r.Remaining > 0)
{
last = r.U32();
r.Bytes(2 + 2 + 12);
var nameLen = r.U16();
r.Bytes(nameLen);
}
return (span.ToArray(), last, reply.GeneralStatus == 0x06);
}
}
@@ -0,0 +1,52 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public sealed record TemplateAttributes(uint DefinitionWords, uint StructureBytes, ushort Members, ushort Handle);
/// <summary>Template Object (class 0x6C) attribute query and definition read.</summary>
public static class TemplateObjectServices
{
/// <summary>Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
/// 2 (member count) and 1 (handle).</summary>
public static byte[] GetAttributes(ushort templateId) =>
new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
.U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
public static TemplateAttributes ParseAttributes(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Template attributes failed: general 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var count = r.U16();
uint defWords = 0, structBytes = 0; ushort members = 0, handle = 0;
for (var i = 0; i < count; i++)
{
var id = r.U16(); var status = r.U16();
if (status != 0)
throw new AbTransportException(AbStatus.BadReply, $"Template attribute {id} status 0x{status:X4}.");
switch (id)
{
case 4: defWords = r.U32(); break;
case 5: structBytes = r.U32(); break;
case 2: members = r.U16(); break;
case 1: handle = r.U16(); break;
default: throw new AbTransportException(AbStatus.BadReply, $"Unexpected template attribute {id}.");
}
}
return new TemplateAttributes(defWords, structBytes, members, handle);
}
/// <summary>Read Template: <c>4C 03 20 6C 25 00 id offset bytes</c>.</summary>
public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
/// <summary>More ⇔ general status 0x06.</summary>
public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
/// <summary>The buffer <c>CipTemplateObjectDecoder</c> expects: <c>u16 id, u32 defWords,
/// u32 structBytes, u16 members, u16 handle</c> then the raw template bytes.</summary>
public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan<byte> rawTemplate) =>
new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
.U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
}