diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs
new file mode 100644
index 00000000..3048d8ba
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs
@@ -0,0 +1,14 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+/// Creates instances over a
+/// (the process-wide shared pool by default).
+internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory
+{
+ private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
+
+ ///
+ public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
+ new NativeAbLegacyTagRuntime(createParams, _pool);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
new file mode 100644
index 00000000..a3078527
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
@@ -0,0 +1,390 @@
+using System.Buffers.Binary;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Native : PCCC data-table reads and writes carried in Execute PCCC
+/// through the device's shared (one per device connection, never per tag).
+/// Decoded values, exceptions and statuses match .
+///
+internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
+{
+ private const int DefaultPort = 44818;
+ private const int StringDataBytes = 82;
+ private const int StringElementBytes = 84;
+
+ // One process-wide TNS counter: no two in-flight requests (on any connection) share a TNS, so a stale
+ // reply belonging to another tag fails the TNS echo check instead of matching by accident.
+ private static int s_tns;
+
+ private readonly string _plc;
+ private readonly RequestScheduler _scheduler;
+ private readonly ConnectionLease? _lease;
+ private readonly byte[] _requestorId;
+ private readonly ushort _vendor;
+ private readonly uint _serial;
+ private readonly PcccAddress? _address;
+ private readonly Exception? _addressError;
+ private readonly int _elementSize;
+ private readonly int _count;
+ private readonly byte[] _buffer;
+ private int _status;
+
+ /// Creates a runtime that leases the shared connection for its device from .
+ public NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool)
+ : this(p, pool, OptionsFor(p))
+ {
+ }
+
+ private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool, ConnectionOptions options)
+ : this(p, pool.Acquire(KeyFor(p), options), options)
+ {
+ }
+
+ private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, ConnectionLease lease, ConnectionOptions options)
+ : this(p, lease.Scheduler, options.VendorId, options.OriginatorSerial)
+ {
+ _lease = lease;
+ }
+
+ /// Test seam: runs over an explicit scheduler with an explicit requestor identity.
+ internal NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, RequestScheduler scheduler, ushort vendor, uint serial)
+ {
+ _plc = p.LibplctagPlcAttribute;
+ _scheduler = scheduler;
+ _vendor = vendor;
+ _serial = serial;
+ _requestorId = new byte[7];
+ _requestorId[0] = 7;
+ BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor);
+ BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial);
+ _count = Math.Max(1, p.ElementCount);
+
+ try
+ {
+ _address = ToPcccAddress(p.TagName);
+ }
+ catch (AbTransportException ex)
+ {
+ _addressError = ex;
+ }
+
+ _elementSize = _address is null ? 2 : ElementSize(_address);
+ _buffer = new byte[_elementSize * _count];
+ }
+
+ ///
+ /// Routing per PLC family, from the golden captures (Captures/README.md "Routing observed"):
+ /// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a
+ /// 1,0 path is configured, so they are . logixpccc sends an
+ /// Unconnected Send (0x52) with route 01 00 (README line ~129, scratch capture: ab_server
+ /// rejected it, so the shape is read from the client side), so it is .
+ ///
+ ///
+ /// logixpccc framing is UNVERIFIED and differs from libplctag: libplctag uses PLC-5 addressing with
+ /// 0x68/0x67 for LGX_PCCC, while this runtime sends SLC typed 0xA2/0xAA (no capture exists either way).
+ /// plc5 is likewise unverified beyond N-file reads. Neither family should default to Native until proven.
+ ///
+ internal static CipRouting RoutingFor(string plcAttribute) => plcAttribute switch
+ {
+ "logixpccc" => CipRouting.UnconnectedSend,
+ _ => CipRouting.Direct,
+ };
+
+ internal static DeviceKey KeyFor(AbLegacyTagCreateParams p) =>
+ new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
+
+ internal static ConnectionOptions OptionsFor(AbLegacyTagCreateParams p)
+ {
+ var timeout = p.Timeout > TimeSpan.Zero && p.Timeout != Timeout.InfiniteTimeSpan
+ ? p.Timeout
+ : TimeSpan.FromSeconds(5);
+ return new ConnectionOptions
+ {
+ Routing = RoutingFor(p.LibplctagPlcAttribute),
+ ConnectionSize = 504,
+ AllowPacking = false, // PCCC requests are never MSP-packed
+ Timeout = timeout,
+ };
+ }
+
+ private static PcccAddress ToPcccAddress(string tagName)
+ {
+ var a = AbLegacyAddress.TryParse(tagName)
+ ?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
+ return a.SubElement is not null
+ ? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
+ : new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
+ }
+
+ private static int ElementSize(PcccAddress a) => a.FileLetter switch
+ {
+ "F" or "L" => 4,
+ "ST" => StringElementBytes,
+ "T" or "C" or "R" => a.SubElement is null ? 6 : 2,
+ _ => 2,
+ };
+
+ ///
+ public Task InitializeAsync(CancellationToken cancellationToken) =>
+ // libplctag resolves the tag on create and reports NotFound for a missing address; one read does the same.
+ RunAsync(() => ReadCoreAsync(cancellationToken));
+
+ ///
+ public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken));
+
+ ///
+ public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken));
+
+ ///
+ public int GetStatus() => Volatile.Read(ref _status);
+
+ private async Task RunAsync(Func op)
+ {
+ try
+ {
+ await op().ConfigureAwait(false);
+ Volatile.Write(ref _status, (int)AbStatus.Ok);
+ }
+ catch (AbTransportException ex)
+ {
+ Volatile.Write(ref _status, (int)ex.Status);
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ Volatile.Write(ref _status, (int)AbStatus.BadConnection);
+ throw;
+ }
+ }
+
+ private (int ChunkElements, PcccAddress Address) Plan()
+ {
+ var address = _address ?? throw _addressError!;
+ if (_count > 1 && address.SubElement is not null)
+ throw new AbTransportException(AbStatus.Unsupported, "Arrays of a structured-element sub-element are not supported.");
+ return (Math.Max(1, PcccServices.MaxDataBytes / _elementSize), address);
+ }
+
+ private async Task> ExchangeAsync(Func pccc, CancellationToken ct)
+ {
+ var tns = unchecked((ushort)Interlocked.Increment(ref s_tns));
+ var request = PcccServices.ExecutePccc(_vendor, _serial, pccc(tns));
+ var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
+ return PcccServices.ParseReply(reply, tns, _requestorId);
+ }
+
+ private async Task ReadCoreAsync(CancellationToken ct)
+ {
+ var (chunk, address) = Plan();
+ var plc5 = _plc == "plc5";
+ if (plc5 && (!PcccServices.Plc5Verified || address.FileLetter != "N"))
+ throw new AbTransportException(AbStatus.Unsupported, "PLC-5 reads are only verified for N files.");
+
+ var staged = new byte[_buffer.Length];
+ for (var done = 0; done < _count; done += chunk)
+ {
+ var n = Math.Min(chunk, _count - done);
+ var bytes = n * _elementSize;
+ var at = address with { Element = address.Element + done };
+ var data = await ExchangeAsync(
+ tns => plc5
+ ? PcccServices.Plc5RangeRead(tns, at, (ushort)(bytes / 2), bytes)
+ : PcccServices.SlcTypedRead(tns, at, bytes),
+ ct).ConfigureAwait(false);
+ if (data.Length < bytes)
+ throw new AbTransportException(AbStatus.BadReply, $"PCCC read returned {data.Length} of {bytes} bytes.");
+ if (data.Length > bytes)
+ throw new AbTransportException(AbStatus.TooLarge, $"PCCC read returned {data.Length} bytes, more than the {bytes} requested.");
+ data.Span[..bytes].CopyTo(staged.AsSpan(done * _elementSize));
+ }
+
+ // Only a fully successful read replaces the buffer: a failed chunk never leaves a half-updated value.
+ staged.CopyTo(_buffer, 0);
+ }
+
+ private async Task WriteCoreAsync(CancellationToken ct)
+ {
+ if (_plc == "plc5")
+ throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified.");
+ // libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write
+ // would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse
+ // before anything reaches the wire.
+ if (StatusBit is not null)
+ throw new AbTransportException(AbStatus.Unsupported,
+ "Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport.");
+ var (chunk, address) = Plan();
+ for (var done = 0; done < _count; done += chunk)
+ {
+ var n = Math.Min(chunk, _count - done);
+ var bytes = n * _elementSize;
+ var at = address with { Element = address.Element + done };
+ var slice = _buffer.AsMemory(done * _elementSize, bytes);
+ // A later chunk failing throws: a partial write is reported as failed, never Good.
+ await ExchangeAsync(tns => PcccServices.SlcTypedWrite(tns, at, slice.Span), ct).ConfigureAwait(false);
+ }
+ }
+
+ private ReadOnlySpan Slice(int offset, int length)
+ {
+ if (offset < 0 || offset + length > _buffer.Length)
+ throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
+ return _buffer.AsSpan(offset, length);
+ }
+
+ private Span SliceW(int offset, int length)
+ {
+ if (offset < 0 || offset + length > _buffer.Length)
+ throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
+ return _buffer.AsSpan(offset, length);
+ }
+
+ private short GetInt16(int offset) => BinaryPrimitives.ReadInt16LittleEndian(Slice(offset, 2));
+ private int GetInt32(int offset) => BinaryPrimitives.ReadInt32LittleEndian(Slice(offset, 4));
+ private float GetFloat32(int offset) => BinaryPrimitives.ReadSingleLittleEndian(Slice(offset, 4));
+
+ /// The bit of a status-bit sub-element (T4:0.DN, C5:0.CU, R6:0.EN...), else null.
+ private int? StatusBit => _address is { SubElement: not null, Bit: int b } ? b : null;
+
+ private bool GetBit(int bit) => bit < 16
+ ? (((ushort)GetInt16(0) >> bit) & 1) != 0
+ : (((uint)GetInt32(0) >> bit) & 1) != 0;
+
+ ///
+ public object? DecodeValue(AbLegacyDataType type, int? bitIndex)
+ {
+ // A status-bit sub-element is a BIT tag in libplctag: every scalar getter returns that bit (0/1).
+ if (StatusBit is int sb && type != AbLegacyDataType.String)
+ {
+ var v = GetBit(sb);
+ return type switch
+ {
+ AbLegacyDataType.Bit => v,
+ AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long
+ or AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
+ or AbLegacyDataType.ControlElement => v ? 1 : 0,
+ AbLegacyDataType.Float => v ? 1f : 0f,
+ _ => null,
+ };
+ }
+
+ return type switch
+ {
+ AbLegacyDataType.Bit => bitIndex is int bit ? GetBit(bit) : (GetInt16(0) & 1) != 0,
+ AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)GetInt16(0),
+ AbLegacyDataType.Long => GetInt32(0),
+ AbLegacyDataType.Float => GetFloat32(0),
+ AbLegacyDataType.String => DecodeString(),
+ AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
+ or AbLegacyDataType.ControlElement => GetInt32(0),
+ _ => null,
+ };
+ }
+
+ // SLC ST element: u16 LEN, then 82 data bytes with each byte pair swapped.
+ private string DecodeString()
+ {
+ var len = Math.Clamp((int)(ushort)GetInt16(0), 0, StringDataBytes);
+ var chars = new char[len];
+ for (var i = 0; i < len; i++) chars[i] = (char)Slice(2 + (i ^ 1), 1)[0];
+ return new string(chars);
+ }
+
+ private void SetString(string s)
+ {
+ if (s.Length > StringDataBytes)
+ throw new AbTransportException(AbStatus.TooLarge, $"String of {s.Length} chars exceeds {StringDataBytes}.");
+ var dest = SliceW(0, StringElementBytes);
+ dest.Clear();
+ BinaryPrimitives.WriteUInt16LittleEndian(dest, (ushort)s.Length);
+ for (var i = 0; i < s.Length; i++) dest[2 + (i ^ 1)] = (byte)s[i];
+ }
+
+ ///
+ public object? DecodeArray(AbLegacyDataType type, int count)
+ {
+ // Same layout assumptions as LibplctagLegacyTagRuntime.DecodeArray: element i at i * elementSize
+ // (2 for N/A, 4 for L/F), B files bit-packed (element i is bit i). String and structured arrays unsupported.
+ switch (type)
+ {
+ case AbLegacyDataType.Int:
+ case AbLegacyDataType.AnalogInt:
+ {
+ var arr = new short[count];
+ for (var i = 0; i < count; i++) arr[i] = GetInt16(i * 2);
+ return arr;
+ }
+ case AbLegacyDataType.Long:
+ {
+ var arr = new int[count];
+ for (var i = 0; i < count; i++) arr[i] = GetInt32(i * 4);
+ return arr;
+ }
+ case AbLegacyDataType.Float:
+ {
+ var arr = new float[count];
+ for (var i = 0; i < count; i++) arr[i] = GetFloat32(i * 4);
+ return arr;
+ }
+ case AbLegacyDataType.Bit:
+ {
+ var arr = new bool[count];
+ for (var i = 0; i < count; i++) arr[i] = ((Slice(i / 8, 1)[0] >> (i % 8)) & 1) != 0;
+ return arr;
+ }
+ default:
+ throw new NotSupportedException(
+ $"AbLegacyDataType {type} is not supported as an array element.");
+ }
+ }
+
+ ///
+ public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
+ {
+ switch (type)
+ {
+ case AbLegacyDataType.Bit:
+ if (bitIndex is int)
+ throw new NotSupportedException(
+ "Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
+ BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToBoolean(value) ? (short)1 : (short)0);
+ break;
+ case AbLegacyDataType.Int:
+ case AbLegacyDataType.AnalogInt:
+ BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToInt16(value));
+ break;
+ case AbLegacyDataType.Long:
+ BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
+ break;
+ case AbLegacyDataType.Float:
+ BinaryPrimitives.WriteSingleLittleEndian(SliceW(0, 4), Convert.ToSingle(value));
+ break;
+ case AbLegacyDataType.String:
+ SetString(Convert.ToString(value) ?? string.Empty);
+ break;
+ case AbLegacyDataType.TimerElement:
+ case AbLegacyDataType.CounterElement:
+ case AbLegacyDataType.ControlElement:
+ BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
+ break;
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
+ }
+ }
+
+ /// Releases the lease on the shared device connection (the last release closes it).
+ public void Dispose()
+ {
+ if (_lease is null) return;
+ // Closing does I/O (Forward Close / UnRegisterSession); never block the caller on it.
+ _ = _lease.DisposeAsync().AsTask().ContinueWith(
+ static t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
new file mode 100644
index 00000000..266fb44e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
@@ -0,0 +1,478 @@
+using System.Buffers.Binary;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class NativeAbLegacyTagRuntimeTests
+{
+ // The requestor identity libplctag used in the golden captures, so our request bytes can be compared
+ // to the captured ones apart from the TNS.
+ private const ushort Vendor = 0xF33D;
+ private const uint Serial = 0x21504345;
+
+ private sealed class Harness : IAsyncDisposable
+ {
+ public FakeCipChannel Channel { get; } = new();
+ public RequestScheduler Scheduler { get; }
+ public NativeAbLegacyTagRuntime Runtime { get; }
+
+ public Harness(string plc, string tag, int count = 1)
+ {
+ Scheduler = new RequestScheduler(Channel, allowPacking: false);
+ Runtime = new NativeAbLegacyTagRuntime(
+ new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), count),
+ Scheduler, Vendor, Serial);
+ }
+
+ /// Answers every request with , echoing the request's requestor id and TNS.
+ public Harness Replies(byte[] data) => Responder((_, _) => (0x00, null, data));
+
+ public Harness Responder(Func f)
+ {
+ var n = 0;
+ Channel.Respond = req =>
+ {
+ var (sts, ext, data) = f(req, n++);
+ return Task.FromResult(Echo(req, sts, ext, data));
+ };
+ return this;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ Runtime.Dispose();
+ await Scheduler.DisposeAsync();
+ }
+ }
+
+ // Execute PCCC request: 4B 02 20 67 24 01 | requestor id (7) | cmd 0F, sts 00, tns(2), fnc, ...
+ private static ushort TnsOf(byte[] req) => BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(15));
+ private static byte FncOf(byte[] req) => req[17];
+
+ private static byte[] Echo(byte[] req, byte sts, byte? ext, byte[] data)
+ {
+ var body = new List(req[6..13]) { 0x4F, sts, req[15], req[16] };
+ if (ext is { } e) body.Add(e);
+ body.AddRange(data);
+ return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
+ }
+
+ private static byte[] Words(params ushort[] w)
+ {
+ var b = new byte[w.Length * 2];
+ for (var i = 0; i < w.Length; i++) BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(i * 2), w[i]);
+ return b;
+ }
+
+ /// The PCCC data bytes of a golden capture's first reply (after cmd, sts, tns).
+ private static byte[] GoldenData(string capture)
+ {
+ var f = CaptureFile.Load(capture).First(x => !x.FromClient && CaptureFile.CipPayload(x).Length > 0);
+ return CaptureFile.CipPayload(f)[15..];
+ }
+
+ private static void ShouldMatchCaptureExceptTns(string capture, byte[] sent)
+ {
+ var golden = CaptureFile.CipPayload(CaptureFile.Load(capture).First(x => x.FromClient && CaptureFile.CipPayload(x).Length > 0));
+ sent.Length.ShouldBe(golden.Length);
+ for (var i = 0; i < golden.Length; i++)
+ if (i is not (15 or 16)) sent[i].ShouldBe(golden[i], $"byte {i}");
+ }
+
+ [Fact]
+ public async Task N7_0_read_decodes_the_golden_slc500_value_and_sends_the_captured_request()
+ {
+ await using var h = new Harness("slc500", "N7:0").Replies(GoldenData("slc500-read-n7.hex"));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(4321);
+ h.Runtime.GetStatus().ShouldBe(0);
+ ShouldMatchCaptureExceptTns("slc500-read-n7.hex", h.Channel.Sent.Single());
+ }
+
+ [Fact]
+ public async Task F8_0_read_decodes_the_golden_float()
+ {
+ await using var h = new Harness("slc500", "F8:0").Replies(GoldenData("slc500-read-f8.hex"));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Float, null).ShouldBe(3.5f);
+ ShouldMatchCaptureExceptTns("slc500-read-f8.hex", h.Channel.Sent.Single());
+ }
+
+ [Fact]
+ public async Task MicroLogix_N7_0_read_decodes_the_golden_value()
+ {
+ await using var h = new Harness("micrologix", "N7:0").Replies(GoldenData("micrologix-read-n7.hex"));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(2468);
+ ShouldMatchCaptureExceptTns("micrologix-read-n7.hex", h.Channel.Sent.Single());
+ }
+
+ [Fact]
+ public async Task Plc5_N7_0_read_uses_the_range_read_and_decodes_the_golden_value()
+ {
+ await using var h = new Harness("plc5", "N7:0").Replies(GoldenData("plc5-read-n7.hex"));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(1357);
+ FncOf(h.Channel.Sent.Single()).ShouldBe((byte)0x01);
+ ShouldMatchCaptureExceptTns("plc5-read-n7.hex", h.Channel.Sent.Single());
+ }
+
+ [Fact]
+ public async Task B3_0_bit_5_decodes_from_the_little_endian_word()
+ {
+ await using var h = new Harness("slc500", "B3:0/5").Replies(Words(0x0020));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Bit, 5).ShouldBe(true);
+ h.Runtime.DecodeValue(AbLegacyDataType.Bit, 4).ShouldBe(false);
+ h.Runtime.DecodeValue(AbLegacyDataType.Bit, null).ShouldBe(false); // no index: bit 0 of the word
+ }
+
+ [Fact]
+ public async Task A_bit_above_15_in_an_L_file_decodes_from_the_32_bit_word()
+ {
+ await using var h = new Harness("slc500", "L9:0/20").Replies([0x00, 0x00, 0x10, 0x00]);
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Runtime.DecodeValue(AbLegacyDataType.Bit, 20).ShouldBe(true);
+ h.Runtime.DecodeValue(AbLegacyDataType.Long, null).ShouldBe(0x100000);
+ }
+
+ [Fact]
+ public async Task An_N7_array_of_5_reads_ten_bytes_and_decodes_each_element()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 5).Replies(Words(10, 20, 30, 0xFFFF, 50));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ var req = h.Channel.Sent.Single();
+ req[18].ShouldBe((byte)10); // byte count
+ h.Runtime.DecodeArray(AbLegacyDataType.Int, 5).ShouldBe(new short[] { 10, 20, 30, -1, 50 });
+ }
+
+ [Fact]
+ public async Task A_B_file_array_decodes_bit_packed()
+ {
+ await using var h = new Harness("slc500", "B3:0", count: 2).Replies(Words(0x0005, 0x0000));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ var bits = (bool[])h.Runtime.DecodeArray(AbLegacyDataType.Bit, 4)!;
+ bits.ShouldBe(new[] { true, false, true, false });
+ }
+
+ [Fact]
+ public async Task T4_0_ACC_addresses_word_2_of_the_timer_and_reads_two_bytes()
+ {
+ await using var h = new Harness("slc500", "T4:0.ACC").Replies(Words(500));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ var req = h.Channel.Sent.Single();
+ // 0F 00 tns(2) A2 byteCount file type element sub (file 4, type T = 0x86, element 0, sub-element 2)
+ req[13..].Where((_, i) => i is 0 or 1 or 4 or 5 or 6 or 7 or 8 or 9).ToArray()
+ .ShouldBe(new byte[] { 0x0F, 0x00, 0xA2, 0x02, 0x04, 0x86, 0x00, 0x02 });
+ h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(500);
+ }
+
+ [Fact]
+ public async Task A_whole_timer_reads_six_bytes_and_decodes_the_first_four_as_Int32()
+ {
+ await using var h = new Harness("slc500", "T4:1").Replies([0x01, 0x00, 0x02, 0x00, 0x03, 0x00]);
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Channel.Sent.Single()[18].ShouldBe((byte)6);
+ h.Runtime.DecodeValue(AbLegacyDataType.TimerElement, null).ShouldBe(0x00020001);
+ }
+
+ [Fact]
+ public async Task A_status_bit_sub_element_decodes_its_bit_from_word_0()
+ {
+ await using var h = new Harness("slc500", "T4:0.DN").Replies(Words(1 << 13));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Channel.Sent.Single()[18].ShouldBe((byte)2);
+ h.Runtime.DecodeValue(AbLegacyDataType.Bit, null).ShouldBe(true);
+ }
+
+ [Fact]
+ public async Task A_string_decodes_with_each_byte_pair_swapped()
+ {
+ var data = new byte[84];
+ data[0] = 5; // LEN
+ // "hello" stored pair-swapped: eh ll \0o
+ data[2] = (byte)'e'; data[3] = (byte)'h'; data[4] = (byte)'l'; data[5] = (byte)'l'; data[6] = 0; data[7] = (byte)'o';
+ await using var h = new Harness("slc500", "ST9:0").Replies(data);
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Channel.Sent.Single()[18].ShouldBe((byte)84);
+ h.Runtime.DecodeValue(AbLegacyDataType.String, null).ShouldBe("hello");
+ }
+
+ [Fact]
+ public async Task A_string_write_encodes_len_and_swapped_pairs()
+ {
+ await using var h = new Harness("slc500", "ST9:0").Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.String, null, "hello");
+ await h.Runtime.WriteAsync(CancellationToken.None);
+ var data = h.Channel.Sent.Single()[23..];
+ data.Length.ShouldBe(84);
+ data[0].ShouldBe((byte)5);
+ data[1].ShouldBe((byte)0);
+ data[2..8].ShouldBe(new[] { (byte)'e', (byte)'h', (byte)'l', (byte)'l', (byte)0, (byte)'o' });
+ }
+
+ [Fact]
+ public async Task STS_0x20_on_initialize_is_NotFound()
+ {
+ await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, []));
+ var ex = await Should.ThrowAsync(() => h.Runtime.InitializeAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.NotFound);
+ h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound);
+ }
+
+ [Fact]
+ public async Task Initialize_performs_exactly_one_read()
+ {
+ await using var h = new Harness("slc500", "N7:0").Replies(Words(1));
+ await h.Runtime.InitializeAsync(CancellationToken.None);
+ h.Channel.SentCount.ShouldBe(1);
+ h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Writing_N7_0_equals_42_sends_the_expected_PCCC_bytes()
+ {
+ await using var h = new Harness("slc500", "N7:0").Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 42);
+ await h.Runtime.WriteAsync(CancellationToken.None);
+ var req = h.Channel.Sent.Single();
+ req[..6].ShouldBe(new byte[] { 0x4B, 0x02, 0x20, 0x67, 0x24, 0x01 });
+ req[6..13].ShouldBe(new byte[] { 0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21 });
+ var tns = TnsOf(req);
+ req[13..].ShouldBe(new byte[] { 0x0F, 0x00, (byte)tns, (byte)(tns >> 8), 0xAA, 0x02, 0x07, 0x89, 0x00, 0x00, 0x2A, 0x00 });
+ h.Runtime.GetStatus().ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task The_TNS_increments_per_request()
+ {
+ await using var h = new Harness("slc500", "N7:0").Replies(Words(1));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ TnsOf(h.Channel.Sent[1]).ShouldNotBe(TnsOf(h.Channel.Sent[0]));
+ }
+
+ [Fact]
+ public async Task A_256_word_read_is_three_chunks_of_118_118_20_words()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 256).Responder((req, _) =>
+ {
+ // 0F 00 tns A2 byteCount file type element sub (element < 255 or 0xFF-escaped)
+ var bytes = req[18];
+ var elem = req[21] == 0xFF ? BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(22)) : req[21];
+ var words = Enumerable.Range(elem, bytes / 2).Select(i => (ushort)i).ToArray();
+ return (0x00, null, Words(words));
+ });
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ h.Channel.Sent.Select(r => (int)r[18]).ShouldBe(new[] { 236, 236, 40 });
+ var arr = (short[])h.Runtime.DecodeArray(AbLegacyDataType.Int, 256)!;
+ arr.Length.ShouldBe(256);
+ for (var i = 0; i < 256; i++) arr[i].ShouldBe((short)i);
+ }
+
+ [Fact]
+ public async Task A_failure_in_a_later_read_chunk_fails_the_whole_read_and_keeps_the_previous_value()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 256).Responder((_, n) =>
+ n == 1 ? (0x10, null, []) : (0x00, null, new byte[236]));
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ h.Runtime.GetStatus().ShouldBe((int)AbStatus.Unsupported);
+ h.Channel.SentCount.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task A_130_word_write_is_two_chunks_of_118_and_12_words()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 130).Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 7);
+ await h.Runtime.WriteAsync(CancellationToken.None);
+ h.Channel.Sent.Select(r => (int)r[18]).ShouldBe(new[] { 236, 24 });
+ h.Channel.Sent[0][21].ShouldBe((byte)0); // element 0
+ h.Channel.Sent[1][21].ShouldBe((byte)118); // element advances by the words already sent
+ h.Channel.Sent[0][23].ShouldBe((byte)7);
+ }
+
+ [Fact]
+ public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) =>
+ n == 1 ? (0x30, null, []) : (0x00, null, []));
+ var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.NotAllowed);
+ h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed);
+ }
+
+ [Fact]
+ public async Task A_PLC5_write_is_Unsupported_and_sends_nothing()
+ {
+ await using var h = new Harness("plc5", "N7:0").Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 1);
+ var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ h.Channel.SentCount.ShouldBe(0);
+ }
+
+ [Theory]
+ [InlineData("F8:0")]
+ [InlineData("B3:0")]
+ [InlineData("T4:0.ACC")]
+ public async Task A_PLC5_read_outside_N_files_is_Unsupported_and_sends_nothing(string tag)
+ {
+ await using var h = new Harness("plc5", tag).Replies([]);
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ h.Channel.SentCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task A_reply_with_a_different_requestor_id_is_BadReply()
+ {
+ await using var h = new Harness("slc500", "N7:0");
+ h.Channel.Respond = req =>
+ {
+ var echoed = Echo(req, 0, null, Words(1));
+ echoed[4 + 1] ^= 0xFF; // corrupt the echoed requestor id
+ return Task.FromResult(echoed);
+ };
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.BadReply);
+ }
+
+ [Fact]
+ public async Task A_reply_with_a_stale_TNS_is_BadReply()
+ {
+ await using var h = new Harness("slc500", "N7:0");
+ h.Channel.Respond = req =>
+ {
+ var echoed = Echo(req, 0, null, Words(1));
+ echoed[4 + 7 + 2] ^= 0x01; // flip a TNS bit
+ return Task.FromResult(echoed);
+ };
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.BadReply);
+ }
+
+ [Fact]
+ public async Task An_over_long_data_reply_is_TooLarge()
+ {
+ await using var h = new Harness("slc500", "N7:0").Replies(Words(1, 2));
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.TooLarge);
+ }
+
+ [Theory]
+ [InlineData("T4:0.DN")]
+ [InlineData("C5:0.CU")]
+ [InlineData("R6:0.EN")]
+ public async Task A_write_to_a_status_bit_sub_element_is_Unsupported_and_sends_nothing(string tag)
+ {
+ await using var h = new Harness("slc500", tag).Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.Bit, null, true);
+ var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ h.Channel.SentCount.ShouldBe(0);
+ }
+
+ [Theory]
+ [InlineData("T4:0.PRE")]
+ [InlineData("T4:0.ACC")]
+ [InlineData("R6:0.LEN")]
+ [InlineData("R6:0.POS")]
+ public async Task A_write_to_a_word_sub_element_still_works(string tag)
+ {
+ await using var h = new Harness("slc500", tag).Replies([]);
+ h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 9);
+ await h.Runtime.WriteAsync(CancellationToken.None);
+ h.Channel.SentCount.ShouldBe(1);
+ }
+
+ [Theory]
+ [InlineData(AbLegacyDataType.Bit)]
+ [InlineData(AbLegacyDataType.Int)]
+ [InlineData(AbLegacyDataType.AnalogInt)]
+ [InlineData(AbLegacyDataType.Long)]
+ [InlineData(AbLegacyDataType.TimerElement)]
+ [InlineData(AbLegacyDataType.CounterElement)]
+ [InlineData(AbLegacyDataType.ControlElement)]
+ public async Task Every_scalar_type_decodes_a_status_bit_sub_element_as_that_bit(AbLegacyDataType type)
+ {
+ foreach (var (word, expectSet) in new[] { ((ushort)0xE000, true), ((ushort)0x0000, false) })
+ {
+ await using var h = new Harness("slc500", "T4:0.DN").Replies(Words(word));
+ await h.Runtime.ReadAsync(CancellationToken.None);
+ var v = h.Runtime.DecodeValue(type, null);
+ if (type == AbLegacyDataType.Bit) v.ShouldBe(expectSet);
+ else v.ShouldBe(expectSet ? 1 : 0);
+ }
+ }
+
+ [Fact]
+ public async Task A_short_data_reply_is_BadReply()
+ {
+ await using var h = new Harness("slc500", "N7:0", count: 5).Replies(Words(1, 2));
+ var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.BadReply);
+ }
+
+ [Fact]
+ public async Task Bit_with_an_index_cannot_be_encoded()
+ {
+ await using var h = new Harness("slc500", "B3:0/5");
+ Should.Throw(() => h.Runtime.EncodeValue(AbLegacyDataType.Bit, 5, true));
+ }
+
+ [Fact]
+ public async Task An_unparseable_address_fails_the_operation_as_Unsupported()
+ {
+ await using var h = new Harness("slc500", "not-an-address");
+ var ex = await Should.ThrowAsync(() => h.Runtime.InitializeAsync(CancellationToken.None));
+ ex.Status.ShouldBe(AbStatus.Unsupported);
+ }
+
+ [Theory]
+ [InlineData("slc500", CipRouting.Direct)]
+ [InlineData("micrologix", CipRouting.Direct)]
+ [InlineData("plc5", CipRouting.Direct)]
+ // Captures/README.md "Routing observed": logixpccc sends Unconnected Send (0x52) with route 01 00.
+ [InlineData("logixpccc", CipRouting.UnconnectedSend)]
+ public void Routing_follows_the_captures_per_family(string plc, CipRouting expected)
+ {
+ NativeAbLegacyTagRuntime.RoutingFor(plc).ShouldBe(expected);
+ var p = new AbLegacyTagCreateParams("h", 44818, "1,0", plc, "N7:0", TimeSpan.FromSeconds(3));
+ var o = NativeAbLegacyTagRuntime.OptionsFor(p);
+ o.Routing.ShouldBe(expected);
+ o.ConnectionSize.ShouldBe(504);
+ o.AllowPacking.ShouldBeFalse();
+ o.Timeout.ShouldBe(TimeSpan.FromSeconds(3));
+ }
+
+ [Fact]
+ public void The_device_key_defaults_the_port_to_44818()
+ {
+ var p = new AbLegacyTagCreateParams("plc1", 0, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(3));
+ NativeAbLegacyTagRuntime.KeyFor(p).ShouldBe(new DeviceKey("plc1", 44818, "1,0", "slc500"));
+ var q = p with { Port = 2222 };
+ NativeAbLegacyTagRuntime.KeyFor(q).Port.ShouldBe(2222);
+ }
+
+ [Fact]
+ public async Task The_factory_leases_the_pooled_connection_and_releases_it_on_dispose()
+ {
+ var pool = new DeviceSessionPool();
+ var factory = new NativeAbLegacyTagFactory(pool);
+ var p = new AbLegacyTagCreateParams("127.0.0.1", 1, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(1));
+ var a = factory.Create(p);
+ var b = factory.Create(p with { TagName = "N7:1" });
+ a.ShouldBeOfType();
+ a.Dispose();
+ b.Dispose();
+ await Task.Yield();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
index b86a5a81..0dd8f992 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj
@@ -21,6 +21,14 @@
+
+
+
+
+
+
+
+