feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam

This commit is contained in:
Joseph Doherty
2026-10-07 15:34:34 -04:00
parent b6f64c369a
commit a0897fda96
15 changed files with 318 additions and 0 deletions
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
public enum AbStatus
{
Ok = 0,
Pending = 1,
Timeout = -1,
NotFound = -2,
NotAllowed = -3,
OutOfBounds = -4,
TooLarge = -5,
TooSmall = -6,
Unsupported = -7,
BadConnection = -8,
BadReply = -9,
Partial = -10,
Abort = -11,
TypeMismatch = -12,
DeviceStateConflict = -13,
}
@@ -0,0 +1,31 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
public static class AbStatusCodes
{
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
_ => AbStatus.BadReply,
};
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
};
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
: Exception(message, inner)
{
public AbStatus Status { get; } = status;
}
@@ -0,0 +1,22 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
/// status, extended status words, and the reply data.</summary>
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
{
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
public static CipReply Parse(ReadOnlyMemory<byte> raw)
{
var r = new CipReader(raw.Span);
var service = (byte)(r.U8() & 0x7F);
r.U8(); // reserved
var general = r.U8();
var extWords = r.U8();
var ext = new ushort[extWords];
for (var i = 0; i < extWords; i++) ext[i] = r.U16();
var data = raw[r.Position..];
return new CipReply(service, general, ext, data);
}
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
public interface ICipChannel
{
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
int MaxRequestBytes { get; }
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
}
@@ -0,0 +1,26 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
public ref struct CipReader(ReadOnlySpan<byte> data)
{
private readonly ReadOnlySpan<byte> _data = data;
public int Position { get; private set; }
public readonly int Remaining => _data.Length - Position;
private ReadOnlySpan<byte> Take(int n)
{
if (n < 0 || Position + n > _data.Length)
throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
var s = _data.Slice(Position, n);
Position += n;
return s;
}
public byte U8() => Take(1)[0];
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
public ReadOnlySpan<byte> Rest() => Take(Remaining);
}
@@ -0,0 +1,29 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
public sealed class CipWriter
{
private byte[] _buf = new byte[256];
public int Length { get; private set; }
private Span<byte> Take(int n)
{
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
var s = _buf.AsSpan(Length, n);
Length += n;
return s;
}
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
</ItemGroup>
</Project>
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>