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:
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
@@ -69,6 +70,17 @@ public interface IMxGatewayClient : IAsyncDisposable
|
||||
/// <param name="onUpdate">Callback invoked per advised-tag value change.</param>
|
||||
/// <param name="ct">Cancellation token; ends the loop when cancelled.</param>
|
||||
Task RunEventLoopAsync(Action<MxValueUpdate> onUpdate, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Long-running consumer of the gateway's session-less StreamAlarms feed. Emits a
|
||||
/// Snapshot…SnapshotComplete replay of active alarms then live transitions. Re-opens
|
||||
/// the stream internally on transport faults (the source replays a fresh snapshot).
|
||||
/// Completes only when <paramref name="ct"/> is cancelled.
|
||||
/// </summary>
|
||||
/// <param name="alarmFilterPrefix">Optional source-reference prefix to scope the feed; null = gateway-wide.</param>
|
||||
/// <param name="onTransition">Callback invoked per native alarm transition.</param>
|
||||
/// <param name="ct">Cancellation token; ends the loop when cancelled.</param>
|
||||
Task RunAlarmStreamAsync(string? alarmFilterPrefix, Action<NativeAlarmTransition> onTransition, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Builds <see cref="IMxGatewayClient"/> instances.</summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
|
||||
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
|
||||
// Alias the Commons alarm types so their simple names bind here, unambiguous
|
||||
// against the colliding gateway proto enums above.
|
||||
using NativeAlarmTransition = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition;
|
||||
using AlarmConditionState = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmConditionState;
|
||||
using AlarmTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
|
||||
using AlarmShelveState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmShelveState;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapping from MxAccess Gateway alarm-feed proto messages to the
|
||||
/// protocol-neutral <see cref="NativeAlarmTransition"/> shape. The gateway proto
|
||||
/// enums (AlarmConditionState / AlarmTransitionKind) collide with the Commons
|
||||
/// alarm enums, so they are aliased here. Unit-tested without a live gateway.
|
||||
/// </summary>
|
||||
public static class MxGatewayAlarmMapper
|
||||
{
|
||||
/// <summary>Clamps the gateway severity onto the unified 0–1000 scale.</summary>
|
||||
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
|
||||
|
||||
/// <summary>Maps a gateway condition-state + severity to the unified condition.</summary>
|
||||
public static AlarmConditionState MapConditionState(ProtoConditionState state, int severity)
|
||||
{
|
||||
var (active, acked) = state switch
|
||||
{
|
||||
ProtoConditionState.Active => (true, false),
|
||||
ProtoConditionState.ActiveAcked => (true, true),
|
||||
ProtoConditionState.Inactive => (false, true),
|
||||
_ => (false, true)
|
||||
};
|
||||
return new AlarmConditionState(active, acked, Confirmed: null,
|
||||
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
|
||||
}
|
||||
|
||||
/// <summary>Maps a gateway transition kind to the unified transition kind.</summary>
|
||||
public static AlarmTransitionKind MapKind(ProtoTransitionKind kind) => kind switch
|
||||
{
|
||||
ProtoTransitionKind.Raise => AlarmTransitionKind.Raise,
|
||||
ProtoTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
|
||||
ProtoTransitionKind.Clear => AlarmTransitionKind.Clear,
|
||||
ProtoTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
|
||||
_ => AlarmTransitionKind.StateChange
|
||||
};
|
||||
|
||||
/// <summary>Derives the mirrored condition from a transition kind + severity.</summary>
|
||||
private static AlarmConditionState ConditionFromKind(ProtoTransitionKind kind, int severity)
|
||||
{
|
||||
var (active, acked) = kind switch
|
||||
{
|
||||
ProtoTransitionKind.Raise => (true, false),
|
||||
ProtoTransitionKind.Acknowledge => (true, true),
|
||||
ProtoTransitionKind.Retrigger => (true, false),
|
||||
ProtoTransitionKind.Clear => (false, true),
|
||||
_ => (false, true)
|
||||
};
|
||||
return new AlarmConditionState(active, acked, Confirmed: null,
|
||||
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
|
||||
}
|
||||
|
||||
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
|
||||
public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body) => new(
|
||||
SourceReference: body.AlarmFullReference,
|
||||
SourceObjectReference: body.SourceObjectReference,
|
||||
AlarmTypeName: body.AlarmTypeName,
|
||||
Kind: MapKind(body.TransitionKind),
|
||||
Condition: ConditionFromKind(body.TransitionKind, body.Severity),
|
||||
Category: body.Category,
|
||||
Description: body.Description,
|
||||
Message: body.Description,
|
||||
OperatorUser: body.OperatorUser,
|
||||
OperatorComment: body.OperatorComment,
|
||||
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
|
||||
TransitionTime: body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
|
||||
CurrentValue: "",
|
||||
LimitValue: "");
|
||||
|
||||
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
|
||||
public static NativeAlarmTransition SnapshotComplete() => new(
|
||||
"", "", "", AlarmTransitionKind.SnapshotComplete,
|
||||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
|
||||
|
||||
/// <summary>Maps one initial-snapshot <see cref="ActiveAlarmSnapshot"/> entry to a Snapshot transition.</summary>
|
||||
public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot) => new(
|
||||
SourceReference: snapshot.AlarmFullReference,
|
||||
SourceObjectReference: snapshot.SourceObjectReference,
|
||||
AlarmTypeName: snapshot.AlarmTypeName,
|
||||
Kind: AlarmTransitionKind.Snapshot,
|
||||
Condition: MapConditionState(snapshot.CurrentState, snapshot.Severity),
|
||||
Category: snapshot.Category,
|
||||
Description: snapshot.Description,
|
||||
Message: snapshot.Description,
|
||||
OperatorUser: snapshot.OperatorUser,
|
||||
OperatorComment: snapshot.OperatorComment,
|
||||
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
|
||||
TransitionTime: snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
|
||||
CurrentValue: "",
|
||||
LimitValue: "");
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -246,6 +246,61 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RunAlarmStreamAsync(
|
||||
string? alarmFilterPrefix, Action<Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var reconnectDelay = TimeSpan.FromSeconds(5);
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new StreamAlarmsRequest
|
||||
{
|
||||
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||
AlarmFilterPrefix = alarmFilterPrefix ?? string.Empty,
|
||||
};
|
||||
|
||||
await foreach (var message in _client!.StreamAlarmsAsync(request, ct)
|
||||
.WithCancellation(ct).ConfigureAwait(false))
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
switch (message.PayloadCase)
|
||||
{
|
||||
case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm:
|
||||
onTransition(MxGatewayAlarmMapper.MapSnapshot(message.ActiveAlarm));
|
||||
break;
|
||||
case AlarmFeedMessage.PayloadOneofCase.Transition:
|
||||
onTransition(MxGatewayAlarmMapper.MapTransition(message.Transition));
|
||||
break;
|
||||
case AlarmFeedMessage.PayloadOneofCase.SnapshotComplete:
|
||||
onTransition(MxGatewayAlarmMapper.SnapshotComplete());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return; // clean shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"MxGateway alarm stream faulted; reopening in {DelaySeconds}s", reconnectDelay.TotalSeconds);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(reconnectDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -58,6 +58,12 @@ public sealed class FakeMxGatewayClient : IMxGatewayClient, IMxGatewayClientFact
|
||||
ct.ThrowIfCancellationRequested(); // …or FaultEventLoop() faults it to simulate a stream break
|
||||
}
|
||||
|
||||
public Task RunAlarmStreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
Action<ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition> onTransition,
|
||||
CancellationToken ct = default)
|
||||
=> Task.CompletedTask; // no alarm feed in the fake
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>Simulate a stream break so the adapter raises Disconnected.</summary>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
using CommonsTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
|
||||
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
|
||||
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>Task-12: pure MxGateway alarm-feed proto → NativeAlarmTransition mapping.</summary>
|
||||
public class MxGatewayAlarmMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapTransition_AckTransition_IsAcknowledgedWithOperator()
|
||||
{
|
||||
var ev = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = ProtoTransitionKind.Acknowledge,
|
||||
Severity = 600,
|
||||
OperatorUser = "operator1",
|
||||
OperatorComment = "ack",
|
||||
Category = "Process",
|
||||
Description = "hi"
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapTransition(ev);
|
||||
|
||||
Assert.Equal(CommonsTransitionKind.Acknowledge, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.True(t.Condition.Acknowledged);
|
||||
Assert.Equal(600, t.Condition.Severity);
|
||||
Assert.Equal("operator1", t.OperatorUser);
|
||||
Assert.Equal("Tank01", t.SourceObjectReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapConditionState_ActiveAcked_To_ActiveTrue_AckTrue()
|
||||
{
|
||||
var c = MxGatewayAlarmMapper.MapConditionState(ProtoConditionState.ActiveAcked, severity: 600);
|
||||
Assert.True(c.Active);
|
||||
Assert.True(c.Acknowledged);
|
||||
Assert.Equal(600, c.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapSnapshot_ActiveUnacked_IsSnapshotKind()
|
||||
{
|
||||
var snap = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.Hi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.Hi",
|
||||
CurrentState = ProtoConditionState.Active,
|
||||
Severity = 1500 // out of range — must clamp
|
||||
};
|
||||
|
||||
var t = MxGatewayAlarmMapper.MapSnapshot(snap);
|
||||
|
||||
Assert.Equal(CommonsTransitionKind.Snapshot, t.Kind);
|
||||
Assert.True(t.Condition.Active);
|
||||
Assert.False(t.Condition.Acknowledged);
|
||||
Assert.Equal(1000, t.Condition.Severity);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user