Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs
T
Joseph Doherty be421a4cb0 fix(abcip): native browse paging/pacing/template lease (#572); differential equal-refusal + budget isolation (#575)
#572: listing continues at max(instance in page)+1 (no ascending-order assumption, no-progress guard kept);
browsePageDelayMs (default 5 ms) paces pages; DiscoverAsync reads templates through one batch reader
(IAbCipTemplateReaderFactory.CreateBatch) holding one lease.
#575: differential harness reports a same-status template refusal as an equal refusal; offline test pins the
open-attempt budget without relying on the abort ending the phase.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:42 -04:00

148 lines
6.9 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
/// parses. By default the lease is held for one read and released when it completes; a reader built for a
/// batch (<see cref="IAbCipTemplateReaderFactory.CreateBatch"/>) instead keeps one lease per device from its
/// first read until it is disposed.
/// </summary>
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
{
/// <summary>Request bytes of a Read Template that are not data room (service, path, offset, count).</summary>
private const int RequestOverhead = 16;
/// <summary>Bytes of the template definition header not counted in the Read Template length.</summary>
private const int DefinitionHeaderBytes = 23;
/// <summary>Upper bound on a template's Read Template length; Logix templates are far smaller.</summary>
internal const long MaxTemplateBytes = 64 * 1024;
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
private readonly ILogger _logger;
private readonly bool _holdLeases;
private readonly object _heldLock = new();
private readonly Dictionary<DeviceKey, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _held = new();
private bool _disposed;
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
public NativeAbCipTemplateReader(DeviceSessionPool pool, bool holdLeases = false)
: this(p =>
{
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
}, holdLeases: holdLeases)
{
}
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
/// <param name="acquire">Acquires the connection parts for a device.</param>
/// <param name="logger">Receives release failures; null discards them.</param>
/// <param name="holdLeases">True for a batch reader: keep each device's lease until disposed.</param>
internal NativeAbCipTemplateReader(
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire,
ILogger? logger = null, bool holdLeases = false)
{
_acquire = acquire;
_logger = logger ?? NullLogger.Instance;
_holdLeases = holdLeases;
}
/// <summary>The connection parts for one read, and whether this read owns (must release) them.</summary>
private (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release, bool Owned) Lease(AbCipTagCreateParams p)
{
if (!_holdLeases)
{
var one = _acquire(p);
return (one.Scheduler, one.Channel, one.Release, true);
}
var key = NativeAbCipDevice.Key(p);
lock (_heldLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_held.TryGetValue(key, out var held))
_held[key] = held = _acquire(p);
return (held.Scheduler, held.Channel, held.Release, false);
}
}
/// <inheritdoc />
public async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
{
if (templateInstanceId > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
var id = (ushort)templateInstanceId;
var (scheduler, channel, release, owned) = Lease(deviceParams);
try
{
var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
.ConfigureAwait(false);
var attrs = TemplateObjectServices.ParseAttributes(attrReply);
// The CIP-documented Read Template length: definition words * 4 - 23.
var total = checked((long)attrs.DefinitionWords * 4 - DefinitionHeaderBytes);
if (total < 0 || total > MaxTemplateBytes)
throw new AbTransportException(AbStatus.BadReply,
$"Template {id} reports an impossible definition size of {attrs.DefinitionWords} words.");
using var raw = new MemoryStream();
long offset = 0;
while (offset < total)
{
var chunk = (ushort)Math.Min(total - offset, channel.MaxRequestBytes - RequestOverhead);
var reply = await scheduler
.SendAsync(TemplateObjectServices.ReadTemplate(id, (uint)offset, chunk), cancellationToken, exclusive: true)
.ConfigureAwait(false);
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status,
$"Read Template {id} failed: general 0x{reply.GeneralStatus:X2}.");
var more = TemplateObjectServices.ReadHasMore(reply);
if (more && reply.Data.IsEmpty)
throw new AbTransportException(AbStatus.BadReply, $"Read Template {id} reported more data but returned none.");
raw.Write(reply.Data.Span);
offset += reply.Data.Length;
if (!more) break;
}
return TemplateObjectServices.BuildDecoderBuffer(id, attrs, raw.ToArray());
}
finally
{
if (owned) NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Releases the leases a batch reader held; a per-read reader has none.</summary>
public void Dispose()
{
List<Func<ValueTask>> releases;
lock (_heldLock)
{
_disposed = true;
releases = [.. _held.Values.Select(h => h.Release)];
_held.Clear();
}
foreach (var release in releases) NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <inheritdoc />
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
/// <inheritdoc />
public IAbCipTemplateReader CreateBatch() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared, holdLeases: true);
}