feat(dcl): add StaleTagMonitor for heartbeat-based disconnect detection

Composable StaleTagMonitor class in Commons fires a Stale event when no
value is received within a configurable max silence period. Integrated
into both LmxProxyDataConnection and OpcUaDataConnection adapters via
optional HeartbeatTagPath/HeartbeatMaxSilence connection config keys.
When stale, the adapter fires Disconnected triggering the standard
reconnect cycle. 10 unit tests cover timer behavior.
This commit is contained in:
Joseph Doherty
2026-03-24 14:28:11 -04:00
parent 02a7e8abc6
commit d4397910f0
4 changed files with 285 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
namespace ScadaLink.Commons.Types;
/// <summary>
/// Monitors a heartbeat tag subscription for staleness. If no value is received
/// within <see cref="MaxSilence"/>, the <see cref="Stale"/> event fires.
/// Composable into any IDataConnection adapter.
/// </summary>
public sealed class StaleTagMonitor : IDisposable
{
private readonly TimeSpan _maxSilence;
private Timer? _timer;
private volatile bool _staleFired;
public StaleTagMonitor(TimeSpan maxSilence)
{
if (maxSilence <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(maxSilence), "MaxSilence must be positive.");
_maxSilence = maxSilence;
}
/// <summary>
/// Fires when no value has been received within <see cref="MaxSilence"/>.
/// Fires once per stale period — resets after <see cref="OnValueReceived"/> is called.
/// </summary>
public event Action? Stale;
public TimeSpan MaxSilence => _maxSilence;
/// <summary>
/// Start monitoring. The timer begins counting from now.
/// </summary>
public void Start()
{
_staleFired = false;
_timer?.Dispose();
_timer = new Timer(OnTimerElapsed, null, _maxSilence, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Signal that a value was received. Resets the stale timer.
/// </summary>
public void OnValueReceived()
{
_staleFired = false;
_timer?.Change(_maxSilence, Timeout.InfiniteTimeSpan);
}
/// <summary>
/// Stop monitoring and dispose the timer.
/// </summary>
public void Stop()
{
_timer?.Dispose();
_timer = null;
}
public void Dispose()
{
Stop();
}
private void OnTimerElapsed(object? state)
{
if (_staleFired) return;
_staleFired = true;
Stale?.Invoke();
}
}