namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS; /// /// Decorates an so that every wire operation on the device's /// single FOCAS/2 socket is (1) serialized against all other operations and /// (2) time-bounded. /// /// /// FOCAS/2 over TCP:8193 is a strict request→response protocol on ONE socket. The /// driver holds a single per device, but several independent loops /// read from it concurrently — the equipment poll (), the /// fixed-tree loop (FixedTreeLoopAsync), the connectivity probe, and the recycle loop. /// Without serialization, two reads interleave their send(request); read(response) on the /// same socket: one reader consumes the other's response PDU and the victim then blocks forever /// waiting for bytes that never arrive — leaving the bound OPC UA node stuck at /// BadWaitingForInitialData. This was the root cause of FOCAS equipment tags never /// surfacing a value while the probe reported HEALTHY (the probe reads work single-threaded on a /// dev box, but collide deployed once the fixed-tree loop runs concurrently). /// /// The gate ( of count 1) makes each request→response atomic on /// the socket. The per-call timeout ensures a stalled response can never hold the gate — and thus /// the socket — indefinitely; a hung read surfaces as a recoverable error at the configured /// Timeout budget instead of permanent silence. The gate and timeout are paired /// deliberately: a lock around an unbounded read would deadlock all I/O for the device. /// /// and are serialized but NOT bounded by /// this decorator's call timeout — they carry their own budgets (the connect timeout argument and /// the probe's caller-supplied linked token respectively), and double-bounding would shrink them. /// public sealed class SynchronizedFocasClient : IFocasClient { private readonly IFocasClient _inner; private readonly TimeSpan _callTimeout; private readonly SemaphoreSlim _gate = new(1, 1); /// Wraps with per-device serialization + a per-call timeout. /// The underlying FOCAS client to serialize access to. /// /// The budget applied to each data read/write. or negative disables /// the per-call timeout (callers' own cancellation tokens still apply). /// public SynchronizedFocasClient(IFocasClient inner, TimeSpan callTimeout) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _callTimeout = callTimeout; } /// public bool IsConnected => _inner.IsConnected; /// public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken) => RunGatedAsync(ct => _inner.ConnectAsync(address, timeout, ct), cancellationToken); /// public Task ProbeAsync(CancellationToken cancellationToken) => RunGatedAsync(ct => _inner.ProbeAsync(ct), cancellationToken); /// public Task<(object? value, uint status)> ReadAsync( FocasAddress address, FocasDataType type, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken); /// public Task WriteAsync( FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.WriteAsync(address, type, value, ct), cancellationToken); /// public Task> ReadAlarmsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.ReadAlarmsAsync(ct), cancellationToken); /// public Task GetSysInfoAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSysInfoAsync(ct), cancellationToken); /// public Task> GetAxisNamesAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetAxisNamesAsync(ct), cancellationToken); /// public Task> GetSpindleNamesAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSpindleNamesAsync(ct), cancellationToken); /// public Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.ReadDynamicAsync(axisIndex, ct), cancellationToken); /// public Task GetProgramInfoAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken); /// public Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken); /// public Task> GetServoLoadsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetServoLoadsAsync(ct), cancellationToken); /// public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSpindleLoadsAsync(ct), cancellationToken); /// public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetSpindleMaxRpmsAsync(ct), cancellationToken); /// public Task> GetPositionFiguresAsync(CancellationToken cancellationToken) => RunBoundedAsync(ct => _inner.GetPositionFiguresAsync(ct), cancellationToken); /// public void Dispose() { _inner.Dispose(); _gate.Dispose(); } // Gate only — the caller already governs the budget (connect timeout arg / probe linked token). private async Task RunGatedAsync(Func> op, CancellationToken ct) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { return await op(ct).ConfigureAwait(false); } finally { _gate.Release(); } } private async Task RunGatedAsync(Func op, CancellationToken ct) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { await op(ct).ConfigureAwait(false); } finally { _gate.Release(); } } // Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is // the linked (not the caller's) token — callers distinguish it from real cancellation by testing // their own token's IsCancellationRequested. private async Task RunBoundedAsync(Func> op, CancellationToken ct) { await _gate.WaitAsync(ct).ConfigureAwait(false); try { if (_callTimeout <= TimeSpan.Zero) return await op(ct).ConfigureAwait(false); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(_callTimeout); return await op(linked.Token).ConfigureAwait(false); } finally { _gate.Release(); } } }