From c31e6deebf741d997f5f8aeac1b39923964fddf7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 16:45:50 -0400 Subject: [PATCH 1/2] feat(ablegacy): native PCCC tag runtime over the managed transport --- .../NativeAbLegacyTagFactory.cs | 14 + .../NativeAbLegacyTagRuntime.cs | 361 +++++++++++++++ .../NativeAbLegacyTagRuntimeTests.cs | 424 ++++++++++++++++++ ...OM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj | 8 + 4 files changed, 807 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs 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..86f37732 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -0,0 +1,361 @@ +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; + + // Seeds each runtime's TNS counter apart from the others so a stale reply belonging to another tag on + // the same connection fails the TNS echo check instead of matching by accident. + private static int s_tnsSeed; + + 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 ushort _tns; + 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); + _tns = (ushort)Interlocked.Add(ref s_tnsSeed, 1000); + _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 . + /// + 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 = ++_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."); + 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."); + 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)); + + 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) => type switch + { + // A status-bit sub-element (T4:0.DN) carries its bit on the transport address; an explicit /N wins. + AbLegacyDataType.Bit => bitIndex is int bit + ? GetBit(bit) + : _address is { SubElement: not null, Bit: int subBit } ? GetBit(subBit) : (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..761a0a0d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs @@ -0,0 +1,424 @@ +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); + ((ushort)(TnsOf(h.Channel.Sent[1]) - TnsOf(h.Channel.Sent[0]))).ShouldBe((ushort)1); + } + + [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 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 @@ + + + + + + + + From b249c15c1a4dca1ecc5968082e72df88e9ed97f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 16:54:18 -0400 Subject: [PATCH 2/2] fix(ablegacy): refuse status-bit writes, decode status bits as 0/1, reject over-long replies, global TNS --- .../NativeAbLegacyTagRuntime.cs | 67 +++++++++++++------ .../NativeAbLegacyTagRuntimeTests.cs | 56 +++++++++++++++- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs index 86f37732..a3078527 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -16,9 +16,9 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime private const int StringDataBytes = 82; private const int StringElementBytes = 84; - // Seeds each runtime's TNS counter apart from the others so a stale reply belonging to another tag on - // the same connection fails the TNS echo check instead of matching by accident. - private static int s_tnsSeed; + // 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; @@ -31,7 +31,6 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime private readonly int _elementSize; private readonly int _count; private readonly byte[] _buffer; - private ushort _tns; private int _status; /// Creates a runtime that leases the shared connection for its device from . @@ -62,7 +61,6 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime _requestorId[0] = 7; BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor); BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial); - _tns = (ushort)Interlocked.Add(ref s_tnsSeed, 1000); _count = Math.Max(1, p.ElementCount); try @@ -85,6 +83,11 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime /// 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, @@ -172,7 +175,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime private async Task> ExchangeAsync(Func pccc, CancellationToken ct) { - var tns = ++_tns; + 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); @@ -198,6 +201,8 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime 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)); } @@ -209,6 +214,12 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime { 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) { @@ -239,25 +250,43 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime 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) => type switch + public object? DecodeValue(AbLegacyDataType type, int? bitIndex) { - // A status-bit sub-element (T4:0.DN) carries its bit on the transport address; an explicit /N wins. - AbLegacyDataType.Bit => bitIndex is int bit - ? GetBit(bit) - : _address is { SubElement: not null, Bit: int subBit } ? GetBit(subBit) : (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, - }; + // 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() 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 index 761a0a0d..266fb44e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs @@ -255,7 +255,7 @@ public sealed class NativeAbLegacyTagRuntimeTests await using var h = new Harness("slc500", "N7:0").Replies(Words(1)); await h.Runtime.ReadAsync(CancellationToken.None); await h.Runtime.ReadAsync(CancellationToken.None); - ((ushort)(TnsOf(h.Channel.Sent[1]) - TnsOf(h.Channel.Sent[0]))).ShouldBe((ushort)1); + TnsOf(h.Channel.Sent[1]).ShouldNotBe(TnsOf(h.Channel.Sent[0])); } [Fact] @@ -359,6 +359,60 @@ public sealed class NativeAbLegacyTagRuntimeTests 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() {