From a0897fda965882d64395577568587846c70d7ba7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 15:34:34 -0400 Subject: [PATCH] feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam --- ZB.MOM.WW.OtOpcUa.slnx | 2 + ...-10-07-native-ab-transport-gate-results.md | 21 ++++++++ .../AbStatus.cs | 22 ++++++++ .../AbStatusCodes.cs | 31 +++++++++++ .../AbTransportException.cs | 9 ++++ .../Cip/CipReply.cs | 22 ++++++++ .../Cip/ICipChannel.cs | 13 +++++ .../Wire/CipReader.cs | 26 +++++++++ .../Wire/CipWriter.cs | 29 ++++++++++ ....MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj | 19 +++++++ .../ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj | 1 + .../ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj | 1 + .../AbStatusCodesTests.cs | 53 +++++++++++++++++++ .../Wire/CipReplyTests.cs | 43 +++++++++++++++ ...W.OtOpcUa.Driver.Ab.Transport.Tests.csproj | 26 +++++++++ 15 files changed, 318 insertions(+) create mode 100644 docs/plans/2026-10-07-native-ab-transport-gate-results.md create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 85b032b2..fd2ce393 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -40,6 +40,7 @@ + @@ -109,6 +110,7 @@ + diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md new file mode 100644 index 00000000..4f9ec0ad --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -0,0 +1,21 @@ +# Native AB transport gate results + +## ab_server suites (T17) + +_(pending)_ + +## Differential vs ab_server (T18) + +_(pending)_ + +## AdminUI /run (T19) + +_(pending)_ + +## Real press (T21) + +_(pending)_ + +## Latency (T21) + +_(pending)_ diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs new file mode 100644 index 00000000..73006ab7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// Transport-neutral status shared by the libplctag and native AB transports. +/// Zero is success; every error is negative so existing status != 0 checks stay correct. +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, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs new file mode 100644 index 00000000..cec4866e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs @@ -0,0 +1,31 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +public static class AbStatusCodes +{ + public static AbStatus FromCip(byte general, ReadOnlySpan 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, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs new file mode 100644 index 00000000..baa6694e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// Thrown by both AB transports. drives OPC UA status mapping and the +/// driver's "controller answered, don't throttle the device" classification (05/STAB-8). +public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null) + : Exception(message, inner) +{ + public AbStatus Status { get; } = status; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs new file mode 100644 index 00000000..b43f9059 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs @@ -0,0 +1,22 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general +/// status, extended status words, and the reply data. +public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory Data) +{ + public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus); + + public static CipReply Parse(ReadOnlyMemory 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs new file mode 100644 index 00000000..42e0b9e1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// 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. +public interface ICipChannel +{ + /// Largest CIP request (service byte through end of data) one call may carry. + int MaxRequestBytes { get; } + + /// Send one CIP request and return the raw CIP reply bytes (reply-service byte first). + /// Throws on transport failure or timeout. + Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs new file mode 100644 index 00000000..e83d48b0 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs @@ -0,0 +1,26 @@ +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply). +public ref struct CipReader(ReadOnlySpan data) +{ + private readonly ReadOnlySpan _data = data; + public int Position { get; private set; } + public readonly int Remaining => _data.Length - Position; + + private ReadOnlySpan 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 Bytes(int n) => Take(n); + public ReadOnlySpan Rest() => Take(Remaining); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs new file mode 100644 index 00000000..3489b4bb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs @@ -0,0 +1,29 @@ +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Growable little-endian writer for CIP / EIP frames. +public sealed class CipWriter +{ + private byte[] _buf = new byte[256]; + public int Length { get; private set; } + + private Span 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 v) { v.CopyTo(Take(v.Length)); return this; } + public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; } + + /// Overwrite a u16 already written at (length back-patching). + public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v); + + public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray(); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj new file mode 100644 index 00000000..2f48e07a --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index 50b2cae4..13e4c766 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index 167eb613..b4fe82f3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs new file mode 100644 index 00000000..8cadaa43 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Xunit; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests; + +public sealed class AbStatusCodesTests +{ + [Theory] + [InlineData((byte)0x00, AbStatus.Ok)] + [InlineData((byte)0x04, AbStatus.NotFound)] + [InlineData((byte)0x05, AbStatus.NotFound)] + [InlineData((byte)0x16, AbStatus.NotFound)] + [InlineData((byte)0x06, AbStatus.Partial)] + [InlineData((byte)0x08, AbStatus.Unsupported)] + [InlineData((byte)0x0F, AbStatus.NotAllowed)] + [InlineData((byte)0x10, AbStatus.DeviceStateConflict)] + [InlineData((byte)0x13, AbStatus.TooSmall)] + [InlineData((byte)0x15, AbStatus.TooLarge)] + [InlineData((byte)0x0B, AbStatus.Ok)] + [InlineData((byte)0x0A, AbStatus.OutOfBounds)] + [InlineData((byte)0x0C, AbStatus.DeviceStateConflict)] + [InlineData((byte)0x0E, AbStatus.NotAllowed)] + [InlineData((byte)0x1E, AbStatus.BadReply)] + [InlineData((byte)0x01, AbStatus.BadReply)] + public void Cip_general_status_maps(byte general, AbStatus expected) => + AbStatusCodes.FromCip(general, ReadOnlySpan.Empty).ShouldBe(expected); + + [Theory] + [InlineData((ushort)0x2105, AbStatus.OutOfBounds)] + [InlineData((ushort)0x2107, AbStatus.TypeMismatch)] + [InlineData((ushort)0x9999, AbStatus.BadReply)] + public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) => + AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected); + + [Theory] + [InlineData((byte)0x00, null, AbStatus.Ok)] + [InlineData((byte)0x10, null, AbStatus.Unsupported)] + [InlineData((byte)0x20, null, AbStatus.NotFound)] + [InlineData((byte)0x30, null, AbStatus.NotAllowed)] + [InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)] + [InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)] + [InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)] + [InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)] + public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) => + AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected); + + [Fact] + public void Ok_is_zero_and_every_error_is_negative() + { + ((int)AbStatus.Ok).ShouldBe(0); + foreach (var s in Enum.GetValues().Where(s => s is not AbStatus.Ok and not AbStatus.Pending)) + ((int)s).ShouldBeLessThan(0); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs new file mode 100644 index 00000000..41026c2b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs @@ -0,0 +1,43 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire; + +public sealed class CipReplyTests +{ + [Fact] + public void Parses_success_reply_with_data() + { + // 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00 + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + r.Service.ShouldBe((byte)0x4C); + r.GeneralStatus.ShouldBe((byte)0); + r.ExtendedStatus.Length.ShouldBe(0); + r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + } + + [Fact] + public void Parses_reply_with_one_extended_word() + { + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x07, 0x21 }); + r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2107 }); + r.Status.ShouldBe(AbStatus.TypeMismatch); + } + + [Fact] + public void Parses_error_reply_with_two_extended_words() + { + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 }); + r.GeneralStatus.ShouldBe((byte)0xFF); + r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 }); + r.Data.Length.ShouldBe(0); + r.Status.ShouldBe(AbStatus.OutOfBounds); + } + + [Fact] + public void Truncated_reply_throws_BadReply() + { + var ex = Should.Throw(() => CipReply.Parse(new byte[] { 0xCC, 0x00 })); + ex.Status.ShouldBe(AbStatus.BadReply); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj new file mode 100644 index 00000000..f3153044 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +