feat(driver-galaxy): consume the gateway's session-less alarm model

The mxaccessgw updated alarms to a session-less central monitor:
AcknowledgeAlarm dropped SessionId and alarm transitions now come from
the session-less StreamAlarms feed instead of the per-session worker
StreamEvents stream. The GalaxyDriver no longer compiled against the
updated client.

- GatewayGalaxyAlarmAcknowledger: session-less rewrite — no GalaxyMxSession;
  outcome read from ProtocolStatus (throw) and Hresult (warn).
- New IGalaxyAlarmFeed seam + GatewayGalaxyAlarmFeed: background consumer
  of StreamAlarms that decodes the active-alarm snapshot plus live
  transitions into GalaxyAlarmTransition and reopens the stream on
  transport faults.
- EventPump: drop the dead per-session OnAlarmTransition path; the
  per-session stream no longer carries alarms.
- GalaxyDriver: bridge the feed onto IAlarmSource.OnAlarmEvent; the feed
  starts on SubscribeAlarmsAsync, independent of data subscriptions.
- Tests: replace EventPumpAlarmTests with GatewayGalaxyAlarmFeedTests;
  move the driver alarm-source tests onto the IGalaxyAlarmFeed seam.

Browse needed no change — GatewayGalaxyHierarchySource consumes the
unchanged DiscoverHierarchy contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 03:59:36 -04:00
parent cd2306db66
commit 27a8d05b7c
9 changed files with 713 additions and 557 deletions

View File

@@ -1,6 +1,3 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -10,51 +7,31 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// PR B.2 — pins GalaxyDriver's IAlarmSource implementation. The driver bridges
/// EventPump.OnAlarmTransition (PR B.1) onto IAlarmSource.OnAlarmEvent and
/// forwards Acknowledge through IGalaxyAlarmAcknowledger (production:
/// GatewayGalaxyAlarmAcknowledger calling the gateway's AcknowledgeAlarm RPC
/// from PR E.2).
/// Pins GalaxyDriver's <c>IAlarmSource</c> implementation. The driver bridges the
/// gateway's session-less alarm feed (<see cref="IGalaxyAlarmFeed"/>, production:
/// <c>GatewayGalaxyAlarmFeed</c>) onto <c>IAlarmSource.OnAlarmEvent</c> and forwards
/// Acknowledge through <see cref="IGalaxyAlarmAcknowledger"/> (production:
/// <c>GatewayGalaxyAlarmAcknowledger</c> calling the session-less
/// <c>AcknowledgeAlarm</c> RPC).
/// </summary>
public sealed class GalaxyDriverAlarmSourceTests
{
[Fact]
public async Task SubscribeAlarmsAsync_returns_handle_and_event_fires_after_pump_alarm()
public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
// Subscribe so OnAlarmEvent has a registered handle to fire under.
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
handle.ShouldNotBeNull();
feed.Started.ShouldBeTrue("SubscribeAlarmsAsync must start the alarm feed");
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// SubscribeAsync to start the EventPump (alarm wiring is lazy on first sub).
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
Description = "Tank 01 high-high level",
},
});
// Drain pump events.
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.Critical));
observed.ShouldHaveSingleItem();
observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
@@ -65,30 +42,19 @@ public sealed class GalaxyDriverAlarmSourceTests
}
[Fact]
public async Task OnAlarmEvent_does_not_fire_when_no_subscription_active()
public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// Start the pump via a data subscription so alarm events flow but no alarm
// subscription is registered → OnAlarmEvent is suppressed.
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
await Task.Delay(150);
// No SubscribeAlarmsAsync → the feed is never wired onto the driver, so a
// transition surfaces nowhere.
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
observed.ShouldBeEmpty();
}
@@ -96,29 +62,20 @@ public sealed class GalaxyDriverAlarmSourceTests
[Fact]
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
await Task.Delay(150);
// The feed keeps running (it is session-less and shared), but with no active
// subscription the driver suppresses the bridged event.
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
observed.ShouldBeEmpty();
}
@@ -126,9 +83,9 @@ public sealed class GalaxyDriverAlarmSourceTests
[Fact]
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
var foreignHandle = new ForeignAlarmHandle();
await Should.ThrowAsync<ArgumentException>(() =>
@@ -138,9 +95,9 @@ public sealed class GalaxyDriverAlarmSourceTests
[Fact]
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
var requests = new[]
{
@@ -159,9 +116,9 @@ public sealed class GalaxyDriverAlarmSourceTests
[Fact]
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
{
var subscriber = new ManualSubscriber();
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
using var driver = NewDriver(feed, ack);
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
@@ -173,8 +130,8 @@ public sealed class GalaxyDriverAlarmSourceTests
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber, alarmAcknowledger: null);
var feed = new FakeAlarmFeed();
using var driver = NewDriver(feed, alarmAcknowledger: null);
await Should.ThrowAsync<NotSupportedException>(() =>
driver.AcknowledgeAsync(
@@ -183,7 +140,7 @@ public sealed class GalaxyDriverAlarmSourceTests
}
private static GalaxyDriver NewDriver(
ManualSubscriber subscriber, IGalaxyAlarmAcknowledger? alarmAcknowledger)
IGalaxyAlarmFeed alarmFeed, IGalaxyAlarmAcknowledger? alarmAcknowledger)
{
var options = new GalaxyDriverOptions(
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
@@ -194,10 +151,43 @@ public sealed class GalaxyDriverAlarmSourceTests
driverInstanceId: "drv-1",
options: options,
hierarchySource: null,
dataReader: null,
dataWriter: null,
subscriber: subscriber,
alarmAcknowledger: alarmAcknowledger);
alarmAcknowledger: alarmAcknowledger,
alarmFeed: alarmFeed);
}
private static GalaxyAlarmTransition NewTransition(
string alarmFullReference,
string sourceObjectReference,
GalaxyAlarmTransitionKind kind,
AlarmSeverity severity)
=> new(
AlarmFullReference: alarmFullReference,
SourceObjectReference: sourceObjectReference,
AlarmTypeName: "AnalogLimitAlarm.HiHi",
TransitionKind: kind,
SeverityBucket: severity,
OpcUaSeverity: 800,
RawMxAccessSeverity: 750,
OriginalRaiseTimestampUtc: null,
TransitionTimestampUtc: DateTime.UtcNow,
OperatorUser: string.Empty,
OperatorComment: string.Empty,
Category: "Process",
Description: "Tank 01 high-high level");
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
public bool Started { get; private set; }
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
public void Start() => Started = true;
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
@@ -215,30 +205,4 @@ public sealed class GalaxyDriverAlarmSourceTests
{
public string DiagnosticId => "foreign";
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
var results = new List<SubscribeResult>();
var nextHandle = 100;
foreach (var r in fullReferences)
{
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}