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:
@@ -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,49 +7,40 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR E.7 — pins that the GalaxyDriver populates the extended AlarmEventArgs
|
||||
/// fields (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the
|
||||
/// gateway emits a transition with the rich payload, and leaves them null on
|
||||
/// events that don't carry them.
|
||||
/// Pins that the GalaxyDriver populates the extended AlarmEventArgs fields
|
||||
/// (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the gateway
|
||||
/// alarm feed delivers a transition with the rich payload, and leaves them null on
|
||||
/// transitions that don't carry them.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
using var driver = NewDriver(subscriber);
|
||||
var feed = new FakeAlarmFeed();
|
||||
using var driver = NewDriver(feed);
|
||||
|
||||
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);
|
||||
|
||||
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var ack = raise.AddSeconds(45);
|
||||
await subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Acknowledge,
|
||||
Severity = 750,
|
||||
OriginalRaiseTimestamp = Timestamp.FromDateTime(raise),
|
||||
TransitionTimestamp = Timestamp.FromDateTime(ack),
|
||||
OperatorUser = "alice",
|
||||
OperatorComment = "investigating",
|
||||
Category = "Process",
|
||||
Description = "Tank 01 high-high level",
|
||||
},
|
||||
});
|
||||
feed.Emit(new GalaxyAlarmTransition(
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
SourceObjectReference: "Tank01",
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind: GalaxyAlarmTransitionKind.Acknowledge,
|
||||
SeverityBucket: AlarmSeverity.Critical,
|
||||
OpcUaSeverity: 800,
|
||||
RawMxAccessSeverity: 750,
|
||||
OriginalRaiseTimestampUtc: raise,
|
||||
TransitionTimestampUtc: ack,
|
||||
OperatorUser: "alice",
|
||||
OperatorComment: "investigating",
|
||||
Category: "Process",
|
||||
Description: "Tank 01 high-high level"));
|
||||
|
||||
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].OperatorComment.ShouldBe("investigating");
|
||||
observed[0].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
@@ -62,38 +50,35 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
[Fact]
|
||||
public async Task Raise_transition_without_optional_fields_leaves_them_null()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
using var driver = NewDriver(subscriber);
|
||||
var feed = new FakeAlarmFeed();
|
||||
using var driver = NewDriver(feed);
|
||||
|
||||
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 subscriber.EmitAlarmAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 750,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
},
|
||||
});
|
||||
feed.Emit(new GalaxyAlarmTransition(
|
||||
AlarmFullReference: "Tank01.Level.HiHi",
|
||||
SourceObjectReference: string.Empty,
|
||||
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind: GalaxyAlarmTransitionKind.Raise,
|
||||
SeverityBucket: AlarmSeverity.Critical,
|
||||
OpcUaSeverity: 800,
|
||||
RawMxAccessSeverity: 750,
|
||||
OriginalRaiseTimestampUtc: null,
|
||||
TransitionTimestampUtc: DateTime.UtcNow,
|
||||
OperatorUser: string.Empty,
|
||||
OperatorComment: string.Empty,
|
||||
Category: string.Empty,
|
||||
Description: string.Empty));
|
||||
|
||||
for (var i = 0; i < 20 && observed.Count == 0; i++)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].OperatorComment.ShouldBeNull();
|
||||
observed[0].OriginalRaiseTimestampUtc.ShouldBeNull();
|
||||
observed[0].AlarmCategory.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static GalaxyDriver NewDriver(ManualSubscriber subscriber)
|
||||
private static GalaxyDriver NewDriver(IGalaxyAlarmFeed feed)
|
||||
{
|
||||
var options = new GalaxyDriverOptions(
|
||||
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
||||
@@ -104,35 +89,19 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
|
||||
driverInstanceId: "drv-1",
|
||||
options: options,
|
||||
hierarchySource: null,
|
||||
dataReader: null,
|
||||
dataWriter: null,
|
||||
subscriber: subscriber,
|
||||
alarmAcknowledger: null);
|
||||
alarmFeed: feed);
|
||||
}
|
||||
|
||||
private sealed class ManualSubscriber : IGalaxySubscriber
|
||||
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
|
||||
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
|
||||
{
|
||||
private readonly Channel<MxEvent> _stream =
|
||||
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
|
||||
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
|
||||
|
||||
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 void Start() { }
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
public void Emit(GalaxyAlarmTransition transition)
|
||||
=> OnAlarmTransition?.Invoke(this, transition);
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.1 — pins the EventPump's OnAlarmTransition decode path. Synthetic MxEvents
|
||||
/// with the new family go in; the pump fires <c>OnAlarmTransition</c> with the
|
||||
/// decoded payload + mapped severity bucket; data-change subscribers stay
|
||||
/// untouched.
|
||||
/// </summary>
|
||||
public sealed class EventPumpAlarmTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Dispatches_raise_acknowledge_clear_in_sequence()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
var transitions = new List<GalaxyAlarmTransition>();
|
||||
var dispatched = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "AlarmTest");
|
||||
pump.OnAlarmTransition += (_, transition) =>
|
||||
{
|
||||
lock (transitions)
|
||||
{
|
||||
transitions.Add(transition);
|
||||
if (transitions.Count == 3) dispatched.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
pump.Start();
|
||||
|
||||
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var ack = raise.AddSeconds(30);
|
||||
var clear = ack.AddSeconds(60);
|
||||
|
||||
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
|
||||
AlarmTransitionKind.Raise, severity: 750, transitionTime: raise));
|
||||
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
|
||||
AlarmTransitionKind.Acknowledge, severity: 750, transitionTime: ack,
|
||||
originalRaise: raise, operatorUser: "alice", operatorComment: "investigating"));
|
||||
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
|
||||
AlarmTransitionKind.Clear, severity: 750, transitionTime: clear,
|
||||
originalRaise: raise));
|
||||
|
||||
var completed = await Task.WhenAny(dispatched.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
completed.ShouldBe(dispatched.Task, "all three alarm transitions should dispatch within 2s");
|
||||
|
||||
transitions.Count.ShouldBe(3);
|
||||
|
||||
transitions[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
transitions[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
transitions[0].OpcUaSeverity.ShouldBe(MxAccessSeverityMapper.OpcUaSeverityCritical);
|
||||
transitions[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
transitions[0].TransitionTimestampUtc.ShouldBe(raise);
|
||||
|
||||
transitions[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
|
||||
transitions[1].OperatorUser.ShouldBe("alice");
|
||||
transitions[1].OperatorComment.ShouldBe("investigating");
|
||||
transitions[1].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
|
||||
transitions[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
transitions[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drops_alarm_event_with_unspecified_transition_kind()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
var transitions = new List<GalaxyAlarmTransition>();
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
|
||||
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
|
||||
pump.Start();
|
||||
|
||||
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
|
||||
AlarmTransitionKind.Unspecified, severity: 100,
|
||||
transitionTime: DateTime.UtcNow));
|
||||
|
||||
// Give the pump a beat to drain the channel.
|
||||
await Task.Delay(150);
|
||||
|
||||
transitions.ShouldBeEmpty("alarm transitions with Unspecified kind are decoder failures and must not fire OnAlarmTransition");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drops_alarm_event_with_missing_body()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
var transitions = new List<GalaxyAlarmTransition>();
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
|
||||
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
|
||||
pump.Start();
|
||||
|
||||
// Family marked as alarm-transition but body left empty (worker version skew /
|
||||
// malformed event). Production should count + drop, not throw.
|
||||
await subscriber.EmitRawAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
WorkerSequence = 42,
|
||||
});
|
||||
|
||||
await Task.Delay(150);
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_data_change_and_alarm_events_dispatch_independently()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tank01.Level", ItemHandle: 7)]);
|
||||
|
||||
var dataChanges = new List<DataChangeEventArgs>();
|
||||
var alarms = new List<GalaxyAlarmTransition>();
|
||||
var bothSeen = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "MixedTest");
|
||||
pump.OnDataChange += (_, args) =>
|
||||
{
|
||||
lock (dataChanges)
|
||||
{
|
||||
dataChanges.Add(args);
|
||||
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
pump.OnAlarmTransition += (_, transition) =>
|
||||
{
|
||||
lock (alarms)
|
||||
{
|
||||
alarms.Add(transition);
|
||||
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
pump.Start();
|
||||
|
||||
await subscriber.EmitAsync(itemHandle: 7, value: 41.0);
|
||||
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
|
||||
AlarmTransitionKind.Raise, severity: 600, transitionTime: DateTime.UtcNow));
|
||||
|
||||
var completed = await Task.WhenAny(bothSeen.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
completed.ShouldBe(bothSeen.Task);
|
||||
|
||||
dataChanges.Count.ShouldBe(1);
|
||||
alarms.Count.ShouldBe(1);
|
||||
alarms[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filters_out_unsupported_event_families()
|
||||
{
|
||||
var subscriber = new ManualSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
var transitions = new List<GalaxyAlarmTransition>();
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "FilterTest");
|
||||
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
|
||||
pump.Start();
|
||||
|
||||
// OnWriteComplete and OperationComplete should be silently dropped.
|
||||
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OnWriteComplete });
|
||||
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OperationComplete });
|
||||
|
||||
await Task.Delay(150);
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static MxEvent NewAlarm(
|
||||
string fullReference,
|
||||
AlarmTransitionKind kind,
|
||||
int severity,
|
||||
DateTime transitionTime,
|
||||
DateTime? originalRaise = null,
|
||||
string operatorUser = "",
|
||||
string operatorComment = "")
|
||||
{
|
||||
var body = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = fullReference,
|
||||
SourceObjectReference = fullReference.Split('.')[0],
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = kind,
|
||||
Severity = severity,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(transitionTime),
|
||||
OperatorUser = operatorUser,
|
||||
OperatorComment = operatorComment,
|
||||
Category = "Process",
|
||||
Description = "Tank 01 high-high level",
|
||||
};
|
||||
if (originalRaise is { } orts)
|
||||
{
|
||||
body.OriginalRaiseTimestamp = Timestamp.FromDateTime(orts);
|
||||
}
|
||||
return new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnAlarmTransition,
|
||||
OnAlarmTransition = body,
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
|
||||
=> _stream.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public ValueTask EmitAsync(int itemHandle, double value) =>
|
||||
_stream.Writer.WriteAsync(new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = itemHandle,
|
||||
Value = new MxValue { DoubleValue = value },
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
});
|
||||
|
||||
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
||||
public ValueTask EmitRawAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="GatewayGalaxyAlarmFeed"/> — the session-less consumer of the
|
||||
/// gateway's <c>StreamAlarms</c> feed. Synthetic <see cref="AlarmFeedMessage"/>s go
|
||||
/// in through the stream-factory seam; the feed fires <c>OnAlarmTransition</c> with
|
||||
/// decoded payloads and mapped severity buckets, drops malformed messages, and
|
||||
/// re-opens the stream after a transport fault.
|
||||
/// </summary>
|
||||
public sealed class GatewayGalaxyAlarmFeedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Decodes_active_alarm_snapshot_then_live_transition()
|
||||
{
|
||||
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var messages = new[]
|
||||
{
|
||||
SnapshotMessage("Tank01.Level.HiHi", AlarmConditionState.Active, severity: 750,
|
||||
lastTransition: raise),
|
||||
SnapshotMessage("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked, severity: 500,
|
||||
lastTransition: raise, operatorUser: "alice", operatorComment: "investigating"),
|
||||
new AlarmFeedMessage { SnapshotComplete = true },
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Clear, severity: 750,
|
||||
transitionTime: raise.AddMinutes(5), originalRaise: raise),
|
||||
};
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var got3 = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var feed = new GatewayGalaxyAlarmFeed(
|
||||
(_, ct) => OpenStream(messages, ct), clientName: "FeedTest");
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
lock (observed)
|
||||
{
|
||||
observed.Add(t);
|
||||
if (observed.Count == 3) got3.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
(await Task.WhenAny(got3.Task, Task.Delay(TimeSpan.FromSeconds(2))))
|
||||
.ShouldBe(got3.Task, "snapshot + transition should dispatch within 2s");
|
||||
|
||||
observed.Count.ShouldBe(3);
|
||||
|
||||
// Active snapshot entry → Raise.
|
||||
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
|
||||
observed[0].RawMxAccessSeverity.ShouldBe(750);
|
||||
|
||||
// Acknowledged snapshot entry → Acknowledge, operator fields preserved.
|
||||
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
|
||||
observed[1].OperatorUser.ShouldBe("alice");
|
||||
observed[1].OperatorComment.ShouldBe("investigating");
|
||||
|
||||
// Live transition after snapshot_complete.
|
||||
observed[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
|
||||
observed[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drops_transition_with_unspecified_kind_and_empty_message()
|
||||
{
|
||||
var messages = new[]
|
||||
{
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Unspecified, severity: 100,
|
||||
transitionTime: DateTime.UtcNow),
|
||||
new AlarmFeedMessage(), // empty oneof — version skew
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Raise, severity: 600,
|
||||
transitionTime: DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var gotOne = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var feed = new GatewayGalaxyAlarmFeed(
|
||||
(_, ct) => OpenStream(messages, ct), clientName: "FeedTest");
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
lock (observed)
|
||||
{
|
||||
observed.Add(t);
|
||||
gotOne.TrySetResult(true);
|
||||
}
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
(await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(2))))
|
||||
.ShouldBe(gotOne.Task);
|
||||
|
||||
// Only the well-formed Raise survives; the Unspecified + empty messages drop.
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopens_stream_after_a_transport_fault()
|
||||
{
|
||||
var calls = 0;
|
||||
var liveTransition = new[]
|
||||
{
|
||||
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Raise, severity: 750,
|
||||
transitionTime: DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var gotOne = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var feed = new GatewayGalaxyAlarmFeed(
|
||||
(_, ct) =>
|
||||
{
|
||||
// First open faults; the feed must reconnect and succeed on the retry.
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
throw new InvalidOperationException("synthetic stream fault");
|
||||
}
|
||||
return OpenStream(liveTransition, ct);
|
||||
},
|
||||
clientName: "ReconnectTest",
|
||||
reconnectDelay: TimeSpan.FromMilliseconds(20));
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
observed.Add(t);
|
||||
gotOne.TrySetResult(true);
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
(await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(3))))
|
||||
.ShouldBe(gotOne.Task, "the feed should reopen the stream and deliver after a fault");
|
||||
|
||||
calls.ShouldBeGreaterThanOrEqualTo(2);
|
||||
observed.ShouldHaveSingleItem();
|
||||
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields each message in order, then holds the stream open until the feed is
|
||||
/// disposed — mirrors a live server-streaming RPC that does not complete on its
|
||||
/// own.
|
||||
/// </summary>
|
||||
private static async IAsyncEnumerable<AlarmFeedMessage> OpenStream(
|
||||
IEnumerable<AlarmFeedMessage> messages,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
foreach (var message in messages)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
yield return message;
|
||||
await Task.Yield();
|
||||
}
|
||||
await Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
|
||||
private static AlarmFeedMessage SnapshotMessage(
|
||||
string fullReference,
|
||||
AlarmConditionState state,
|
||||
int severity,
|
||||
DateTime lastTransition,
|
||||
string operatorUser = "",
|
||||
string operatorComment = "")
|
||||
=> new()
|
||||
{
|
||||
ActiveAlarm = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = fullReference,
|
||||
SourceObjectReference = fullReference.Split('.')[0],
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
Severity = severity,
|
||||
CurrentState = state,
|
||||
Category = "Process",
|
||||
Description = "Tank high-high level",
|
||||
LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
|
||||
OperatorUser = operatorUser,
|
||||
OperatorComment = operatorComment,
|
||||
},
|
||||
};
|
||||
|
||||
private static AlarmFeedMessage TransitionMessage(
|
||||
string fullReference,
|
||||
AlarmTransitionKind kind,
|
||||
int severity,
|
||||
DateTime transitionTime,
|
||||
DateTime? originalRaise = null)
|
||||
{
|
||||
var body = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = fullReference,
|
||||
SourceObjectReference = fullReference.Split('.')[0],
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = kind,
|
||||
Severity = severity,
|
||||
TransitionTimestamp = Timestamp.FromDateTime(transitionTime),
|
||||
Category = "Process",
|
||||
Description = "Tank high-high level",
|
||||
};
|
||||
if (originalRaise is { } orts)
|
||||
{
|
||||
body.OriginalRaiseTimestamp = Timestamp.FromDateTime(orts);
|
||||
}
|
||||
return new AlarmFeedMessage { Transition = body };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user