feat(ablegacy): native PCCC tag runtime over the managed transport

This commit is contained in:
Joseph Doherty
2026-10-07 16:45:50 -04:00
parent b82fffd269
commit c31e6deebf
4 changed files with 807 additions and 0 deletions
@@ -0,0 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>Creates <see cref="NativeAbLegacyTagRuntime"/> instances over a <see cref="DeviceSessionPool"/>
/// (the process-wide shared pool by default).</summary>
internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory
{
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
/// <inheritdoc />
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
new NativeAbLegacyTagRuntime(createParams, _pool);
}
@@ -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;
/// <summary>
/// Native <see cref="IAbLegacyTagRuntime"/>: PCCC data-table reads and writes carried in Execute PCCC
/// through the device's shared <see cref="RequestScheduler"/> (one per device connection, never per tag).
/// Decoded values, exceptions and statuses match <see cref="LibplctagLegacyTagRuntime"/>.
/// </summary>
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;
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
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;
}
/// <summary>Test seam: runs over an explicit scheduler with an explicit requestor identity.</summary>
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];
}
/// <summary>
/// Routing per PLC family, from the golden captures (<c>Captures/README.md</c> "Routing observed"):
/// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a
/// <c>1,0</c> path is configured, so they are <see cref="CipRouting.Direct"/>. logixpccc sends an
/// Unconnected Send (0x52) with route <c>01 00</c> (README line ~129, scratch capture: ab_server
/// rejected it, so the shape is read from the client side), so it is <see cref="CipRouting.UnconnectedSend"/>.
/// </summary>
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,
};
/// <inheritdoc />
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));
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken));
/// <inheritdoc />
public int GetStatus() => Volatile.Read(ref _status);
private async Task RunAsync(Func<Task> 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<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> 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<byte> 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<byte> 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;
/// <inheritdoc />
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];
}
/// <inheritdoc />
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.");
}
}
/// <inheritdoc />
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.");
}
}
/// <summary>Releases the lease on the shared device connection (the last release closes it).</summary>
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);
}
}