feat(dcl): MxGateway StreamAlarms adapter (snapshot + live transitions, reconnecting)

Adds IAlarmSubscribableConnection to MxGatewayDataConnection (shared session-less
feed, ref-counted), IMxGatewayClient.RunAlarmStreamAsync over the package
StreamAlarmsAsync with internal reconnect, and MxGatewayAlarmMapper
(AlarmFeedMessage/OnAlarmTransitionEvent -> NativeAlarmTransition). Behavior
verified against a live gateway in Task 28; mapper unit-tested.
This commit is contained in:
Joseph Doherty
2026-05-29 16:49:25 -04:00
parent 0d30b7dec0
commit c7411700dc
6 changed files with 295 additions and 1 deletions
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// <see cref="Disconnected"/>, the actor disposes this adapter, creates a fresh one,
/// reconnects and re-subscribes all tags.
/// </summary>
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
{
private readonly IMxGatewayClientFactory _clientFactory;
private readonly ILogger<MxGatewayDataConnection> _logger;
@@ -28,6 +28,15 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
private ConnectionHealth _status = ConnectionHealth.Disconnected;
private CancellationTokenSource? _eventLoopCts;
// Native alarm feed: the gateway StreamAlarms RPC is session-less and
// gateway-wide, so one shared feed serves the whole connection. The
// DataConnectionActor routes transitions to instances by source reference,
// so a single shared callback (the first registered) suffices; subscriptions
// are ref-counted so the feed stops when the last one is removed.
private CancellationTokenSource? _alarmCts;
private int _alarmSubCount;
private readonly object _alarmLock = new();
// subscriptionId → (tagPath, callback) so the event loop can route updates by tag,
// plus tagPath → subscriptionId for reverse lookup. Concurrent because the event
// loop reads from a background thread while Subscribe/Unsubscribe mutate.
@@ -112,6 +121,13 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
_eventLoopCts?.Cancel();
lock (_alarmLock)
{
_alarmCts?.Cancel();
_alarmCts?.Dispose();
_alarmCts = null;
_alarmSubCount = 0;
}
if (_client is not null)
await _client.DisconnectAsync(cancellationToken);
_status = ConnectionHealth.Disconnected;
@@ -134,6 +150,43 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
/// <inheritdoc />
public Task<string> SubscribeAlarmsAsync(
string sourceReference, string? conditionFilter,
AlarmTransitionCallback callback, CancellationToken cancellationToken = default)
{
lock (_alarmLock)
{
_alarmSubCount++;
if (_alarmCts == null)
{
_alarmCts = new CancellationTokenSource();
var token = _alarmCts.Token;
var client = _client!;
// Gateway-wide feed (null prefix); the actor filters per source reference.
_ = Task.Run(() => client.RunAlarmStreamAsync(null, t => callback(t), token), token);
}
}
return Task.FromResult(Guid.NewGuid().ToString());
}
/// <inheritdoc />
public Task UnsubscribeAlarmsAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
lock (_alarmLock)
{
if (_alarmSubCount > 0)
_alarmSubCount--;
if (_alarmSubCount == 0)
{
_alarmCts?.Cancel();
_alarmCts?.Dispose();
_alarmCts = null;
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<ReadResult> ReadAsync(string tagPath, CancellationToken cancellationToken = default)
{