feat(ablegacy): native PCCC tag runtime over the managed transport
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user