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(); }
}
}