diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
index 4b82047..1ce7860 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandExecutorTests.cs
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
{
- FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
+ FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid();
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid);
- Assert.Equal("alice", handler.LastAckOperatorName);
+ Assert.Equal("ack", handler.LastComment);
+ Assert.Equal("alice", handler.LastOperator!.UserName);
+ Assert.Equal("CORP", handler.LastOperator.Domain);
+ Assert.Equal(string.Empty, handler.LastOperator.Password);
}
/// Verifies that invalid alarm GUID returns invalid request.
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
- /// Verifies that nonzero native status carries a diagnostic message.
+ /// Verifies that a rejected write carries its native status and diagnostic.
[Fact]
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
{
- FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AckResult = AlarmAckResult.Create(AlarmAckOutcome.WriteRejected, -123, "AckMsg write rejected: detail -123."),
+ };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command);
+ Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(-123, reply.Hresult);
+ Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
Assert.Contains("-123", reply.DiagnosticMessage);
}
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
{
- FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
+ FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
- Assert.Equal("alice", handler.LastAckOperatorName);
+ Assert.Equal("ack", handler.LastComment);
+ Assert.Equal("alice", handler.LastOperator!.UserName);
+ }
+
+ ///
+ /// Every ack outcome maps to its protocol status, carries the native
+ /// status, and never echoes the operator password.
+ ///
+ /// The writer outcome.
+ /// The expected protocol status.
+ [Theory]
+ [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
+ [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
+ [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
+ [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
+ [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
+ [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
+ public void AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
+ {
+ int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
+ };
+
+ MxCommandReply reply = Execute(handler, new MxCommand
+ {
+ Kind = MxCommandKind.AcknowledgeAlarm,
+ AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
+ {
+ AlarmGuid = Guid.NewGuid().ToString(),
+ Comment = "checked",
+ OperatorUser = "alice",
+ OperatorDomain = "CORP",
+ OperatorPassword = "pw-secret",
+ },
+ });
+
+ AssertAckReply(reply, handler, expected, native);
+ }
+
+ /// The by-name ack maps outcomes identically and never echoes the operator password.
+ /// The writer outcome.
+ /// The expected protocol status.
+ [Theory]
+ [InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
+ [InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
+ [InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
+ [InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
+ [InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
+ [InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
+ public void AcknowledgeAlarmByName_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
+ {
+ int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
+ };
+
+ MxCommandReply reply = Execute(handler, new MxCommand
+ {
+ Kind = MxCommandKind.AcknowledgeAlarmByName,
+ AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
+ {
+ AlarmName = "TestMachine_001.TestAlarm001",
+ ProviderName = "Galaxy",
+ GroupName = "TestArea",
+ Comment = "checked",
+ OperatorUser = "alice",
+ OperatorDomain = "CORP",
+ OperatorPassword = "pw-secret",
+ },
+ });
+
+ AssertAckReply(reply, handler, expected, native);
+ }
+
+ /// The writer's OnWriteComplete statuses are copied onto the reply.
+ [Fact]
+ public void AcknowledgeAlarm_CopiesWriteStatusesIntoReply()
+ {
+ MxStatusProxy status = new MxStatusProxy { Category = MxStatusCategory.Ok, Success = 1 };
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AckResult = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, new[] { status }),
+ };
+
+ MxCommandReply reply = Execute(handler, new MxCommand
+ {
+ Kind = MxCommandKind.AcknowledgeAlarm,
+ AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand { AlarmGuid = Guid.NewGuid().ToString(), OperatorUser = "alice" },
+ });
+
+ Assert.Equal(status, Assert.Single(reply.Statuses));
+ }
+
+ ///
+ /// An exception escaping the ack path (e.g. the writer's MXAccess
+ /// Register failing) maps to MxaccessFailure with only the exception type
+ /// and HRESULT — never the exception text, which is not under our control.
+ ///
+ [Fact]
+ public void AcknowledgeAlarmByName_WhenHandlerThrows_ReportsTypeAndHresultOnly()
+ {
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AcknowledgeException = new System.Runtime.InteropServices.COMException("echo pw-secret", unchecked((int)0x80004005)),
+ };
+
+ MxCommandReply reply = Execute(handler, new MxCommand
+ {
+ Kind = MxCommandKind.AcknowledgeAlarmByName,
+ AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
+ {
+ AlarmName = "TestMachine_001.TestAlarm001",
+ ProviderName = "Galaxy",
+ OperatorUser = "alice",
+ OperatorPassword = "pw-secret",
+ },
+ });
+
+ Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
+ Assert.Equal(unchecked((int)0x80004005), reply.Hresult);
+ Assert.Contains("COMException", reply.DiagnosticMessage, StringComparison.Ordinal);
+ Assert.Contains("0x80004005", reply.DiagnosticMessage, StringComparison.Ordinal);
+ Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage, StringComparison.Ordinal);
+ Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message, StringComparison.Ordinal);
}
/// Verifies that empty alarm name returns invalid request.
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
[Fact]
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
{
- FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
+ FakeAlarmHandler handler = new FakeAlarmHandler
+ {
+ AcknowledgeException = new InvalidOperationException("simulated alarm-handler failure"),
+ };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
- Assert.Contains("simulated", reply.DiagnosticMessage);
+ Assert.Contains("InvalidOperationException", reply.DiagnosticMessage, StringComparison.Ordinal);
+ Assert.DoesNotContain("simulated", reply.DiagnosticMessage, StringComparison.Ordinal);
+ }
+
+ private static MxCommandReply Execute(IAlarmCommandHandler handler, MxCommand command) =>
+ NewExecutor(handler).Execute(new StaCommand(SessionId, CorrelationId, command));
+
+ private static void AssertAckReply(MxCommandReply reply, FakeAlarmHandler handler, ProtocolStatusCode expected, int native)
+ {
+ Assert.Equal(expected, reply.ProtocolStatus.Code);
+ Assert.Equal(native, reply.Hresult);
+ Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
+ Assert.Equal("diagnostic for alice", reply.DiagnosticMessage);
+ Assert.Equal("checked", handler.LastComment);
+ Assert.Equal("alice", handler.LastOperator!.UserName);
+ Assert.Equal("CORP", handler.LastOperator.Domain);
+ Assert.Equal("pw-secret", handler.LastOperator.Password);
+ Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
+ Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
}
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
/// Gets the last acknowledge alarm GUID.
public Guid LastAckGuid { get; private set; }
- /// Gets the last acknowledge operator name.
- public string? LastAckOperatorName { get; private set; }
+ /// Gets the last acknowledge comment.
+ public string? LastComment { get; private set; }
- /// Gets or sets the value returned by acknowledge.
- public int AcknowledgeReturn { get; set; }
+ /// Gets the last acknowledge operator.
+ public AlarmAckOperator? LastOperator { get; private set; }
- /// Gets or sets a value indicating whether acknowledge throws.
- public bool AcknowledgeThrow { get; set; }
+ /// Gets or sets the result returned by both acknowledge methods.
+ public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
+
+ /// Gets or sets an exception both acknowledge methods throw.
+ public Exception? AcknowledgeException { get; set; }
/// Gets or sets the query result snapshots.
public IReadOnlyList QueryResult { get; set; } =
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
}
///
- public int Acknowledge(
- Guid alarmGuid, string comment, string operatorUser,
- string operatorNode, string operatorDomain, string operatorFullName)
+ public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
{
LastAckGuid = alarmGuid;
- LastAckOperatorName = operatorUser;
- if (AcknowledgeThrow)
+ LastComment = comment;
+ LastOperator = ackOperator;
+ if (AcknowledgeException is not null)
{
- throw new InvalidOperationException("simulated alarm-handler failure");
+ throw AcknowledgeException;
}
- return AcknowledgeReturn;
+
+ return AckResult;
}
///
- public int AcknowledgeByName(
- string alarmName, string providerName, string groupName,
- string comment, string operatorUser, string operatorNode,
- string operatorDomain, string operatorFullName)
+ public AlarmAckResult AcknowledgeByName(
+ string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
- LastAckOperatorName = operatorUser;
- return AcknowledgeReturn;
+ LastComment = comment;
+ LastOperator = ackOperator;
+ if (AcknowledgeException is not null)
+ {
+ throw AcknowledgeException;
+ }
+
+ return AckResult;
}
/// Gets the last acknowledge by name tuple.
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
index c41807a..92c2d2c 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed);
}
- /// Verifies that acknowledge forwards to consumer with full operator identity when subscribed.
+ /// Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.
[Fact]
- public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
+ public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
{
- FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
- AlarmCommandHandler handler = new AlarmCommandHandler(
- new MxAccessEventQueue(),
- () => consumer);
+ FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
+ RecordingAckWriter writer = new RecordingAckWriter();
+ AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Guid g = Guid.NewGuid();
- int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
+ AlarmAckOperator op = new AlarmAckOperator { UserName = "u", Domain = "d", Password = "p" };
+ AlarmAckResult result = handler.Acknowledge(g, "c", op);
- Assert.Equal(0, rc);
- Assert.Equal(g, consumer.LastAckGuid);
- Assert.Equal("u", consumer.LastAckOperatorName);
+ Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
+ Assert.Equal(g, consumer.LastResolvedGuid);
+ Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
+ Assert.Equal("c", writer.LastComment);
+ Assert.Same(op, writer.LastOperator);
+ }
+
+ /// Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.
+ [Fact]
+ public void AcknowledgeByName_WhenSubscribed_ForwardsTupleAndNormalizesComment()
+ {
+ FakeConsumer consumer = new FakeConsumer();
+ RecordingAckWriter writer = new RecordingAckWriter();
+ AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+
+ handler.AcknowledgeByName("Tag1", "Galaxy", "Area", null!, new AlarmAckOperator { UserName = "u" });
+
+ Assert.Equal(("Tag1", "Galaxy", "Area"), consumer.LastResolvedName);
+ Assert.Equal(string.Empty, writer.LastComment);
+ }
+
+ /// Verifies a null operator is rejected.
+ [Fact]
+ public void Acknowledge_NullOperator_Throws()
+ {
+ AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+
+ Assert.Throws(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
+ }
+
+ ///
+ /// The ack writer is created once, on first acknowledgement, survives an
+ /// unsubscribe/resubscribe cycle, and is disposed with the handler.
+ ///
+ [Fact]
+ public void AckWriter_CreatedLazilyOnce_AndDisposedWithHandler()
+ {
+ RecordingAckWriter writer = new RecordingAckWriter();
+ int created = 0;
+ AlarmCommandHandler handler = new AlarmCommandHandler(
+ new MxAccessEventQueue(),
+ () => new FakeConsumer(),
+ threadAffinityCheck: null,
+ comFactory: null,
+ standbyFactory: null,
+ ackWriterFactory: () =>
+ {
+ created++;
+ return writer;
+ });
+
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+ Assert.Equal(0, created);
+
+ handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator());
+ handler.Unsubscribe();
+ Assert.False(writer.Disposed);
+
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+ handler.AcknowledgeByName("a", "Galaxy", "g", "c", new AlarmAckOperator());
+ Assert.Equal(1, created);
+
+ handler.Dispose();
+ Assert.True(writer.Disposed);
+ }
+
+ ///
+ /// Without a COM factory or writer factory, subscribing still works and
+ /// the acknowledgement fails with a diagnosable InvalidOperationException.
+ ///
+ [Fact]
+ public void Acknowledge_WithoutComFactoryOrWriterFactory_ThrowsInvalidOperation()
+ {
+ AlarmCommandHandler handler = new AlarmCommandHandler(new MxAccessEventQueue(), () => new FakeConsumer());
+ handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
+
+ InvalidOperationException ex = Assert.Throws(
+ () => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
+ Assert.Contains("IMxAccessComObjectFactory", ex.Message);
}
/// Verifies that acknowledge throws invalid operation when called before subscribe.
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
() => new FakeConsumer());
Assert.Throws(
- () => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
+ () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
}
/// Verifies that query active returns mapped proto snapshots when consumer has alarms.
@@ -266,7 +344,10 @@ public sealed class AlarmCommandHandlerTests
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer,
- () => guardInvocations++);
+ () => guardInvocations++,
+ comFactory: null,
+ standbyFactory: null,
+ ackWriterFactory: () => new RecordingAckWriter());
// Subscribe is the first call — guard must run before the consumer
// factory is invoked. We tally invocation counts after each call so
@@ -275,10 +356,10 @@ public sealed class AlarmCommandHandlerTests
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Equal(1, guardInvocations);
- handler.Acknowledge(Guid.NewGuid(), "c", "u", "n", "d", "F");
+ handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(2, guardInvocations);
- handler.AcknowledgeByName("a", "p", "g", "c", "u", "n", "d", "F");
+ handler.AcknowledgeByName("a", "p", "g", "c", new AlarmAckOperator { UserName = "u" });
Assert.Equal(3, guardInvocations);
_ = handler.QueryActive(null, out _);
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
// assert each independently with a fresh handler. Below we reuse
// the same throwing handler for the not-subscribed-yet entries:
Assert.Throws(
- () => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
+ () => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
Assert.Throws(
- () => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
+ () => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
Assert.Throws(() => handler.QueryActive(null, out _));
Assert.Throws(() => handler.PollOnce());
Assert.Throws(
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
[Fact]
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
{
- FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
- FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 };
- FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
+ FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
+ FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
+ FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
threadAffinityCheck: null,
comFactory: null,
- standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement);
+ standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
+ ackWriterFactory: () => new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand
{
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(2, buildCount);
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
Assert.True(initialStandby.Disposed);
- Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
+ Assert.Equal("11.AckMsg", AckItemOf(handler));
}
/// A replacement is rejected when no fallback-capable subscription is active.
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
[Fact]
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
{
- FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
- FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
- FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 };
+ FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
+ FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
+ FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
null,
null,
- _ => ++buildCount == 1 ? oldStandby : failedReplacement);
+ _ => ++buildCount == 1 ? oldStandby : failedReplacement,
+ () => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
Assert.False(oldStandby.Disposed);
Assert.True(failedReplacement.Disposed);
- Assert.Equal(11, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
+ Assert.Equal("11.AckMsg", AckItemOf(handler));
}
/// A successful replacement while failed over keeps subtag mode active and delegates to the new standby.
[Fact]
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
{
- FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 };
- FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
- FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
+ FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
+ FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
+ FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
int buildCount = 0;
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
null,
null,
- _ => ++buildCount == 1 ? oldStandby : replacement);
+ _ => ++buildCount == 1 ? oldStandby : replacement,
+ () => new RecordingAckWriter());
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
Assert.True(oldStandby.Disposed);
- Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
+ Assert.Equal("33.AckMsg", AckItemOf(handler));
}
///
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
}
+ private static AlarmCommandHandler NewHandlerWithWriter(Func consumerFactory, IAlarmAckWriter writer) =>
+ new AlarmCommandHandler(
+ new MxAccessEventQueue(),
+ consumerFactory,
+ threadAffinityCheck: null,
+ comFactory: null,
+ standbyFactory: null,
+ ackWriterFactory: () => writer);
+
+ /// Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).
+ private static string AckItemOf(AlarmCommandHandler handler) =>
+ handler.Acknowledge(Guid.Empty, string.Empty, new AlarmAckOperator()).Diagnostic;
+
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{
return new MxAlarmSnapshotRecord
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
/// Gets the last subscription request.
public string? LastSubscription { get; private set; }
- /// Gets the last acknowledged alarm GUID.
- public Guid LastAckGuid { get; private set; }
- /// Gets the last acknowledged operator name.
- public string? LastAckOperatorName { get; private set; }
- /// Gets or sets the return value for acknowledge operations.
- public int AcknowledgeReturn { get; set; }
+ /// Gets or sets the ack target both resolve methods return.
+ public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
+ /// Gets the GUID from the last ResolveAckTarget call.
+ public Guid LastResolvedGuid { get; private set; }
+ /// Gets the (name, provider, group) from the last ResolveAckTargetByName call.
+ public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// Gets or sets the snapshot result to return.
public IReadOnlyList SnapshotResult { get; set; } =
Array.Empty();
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
}
///
- public int AcknowledgeByGuid(
- Guid alarmGuid, string ackComment, string ackOperatorName,
- string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
+ public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
- LastAckGuid = alarmGuid;
- LastAckOperatorName = ackOperatorName;
- return AcknowledgeReturn;
+ LastResolvedGuid = alarmGuid;
+ return AckTarget;
}
///
- public int AcknowledgeByName(
- string alarmName, string providerName, string groupName,
- string ackComment, string ackOperatorName, string ackOperatorNode,
- string ackOperatorDomain, string ackOperatorFullName)
+ public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
- LastAckByNameTuple = (alarmName, providerName, groupName);
- LastAckOperatorName = ackOperatorName;
- return AcknowledgeReturn;
+ LastResolvedName = (alarmName, providerName, groupName);
+ return AckTarget;
}
- /// Gets the last acknowledge-by-name parameters.
- public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
-
/// Gets or sets the truncation verdict this consumer reports with its snapshot.
public bool SnapshotTruncated { get; set; }
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
Disposed = true;
}
}
+
+ private sealed class RecordingAckWriter : IAlarmAckWriter
+ {
+ public string? LastItemAddress { get; private set; }
+ public string? LastComment { get; private set; }
+ public AlarmAckOperator? LastOperator { get; private set; }
+ public bool Disposed { get; private set; }
+
+ public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
+ {
+ LastItemAddress = itemAddress;
+ LastComment = comment;
+ LastOperator = ackOperator;
+ return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, itemAddress);
+ }
+
+ public void Dispose() => Disposed = true;
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
index 677dc37..85ffd22 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
@@ -24,7 +24,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent
@@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
@@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
+ SessionId,
+ new FakeAckWriter());
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
}
- /// Verifies that acknowledge calls are forwarded to the consumer with operator identity.
+ /// Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.
[Fact]
- public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
+ public void Acknowledge_ResolvesTargetThenWritesWithOperator()
{
- FakeAlarmConsumer consumer = new FakeAlarmConsumer();
- consumer.AcknowledgeReturn = 0;
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
+ FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
- consumer,
- new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
-
+ consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
Guid guid = Guid.NewGuid();
- int rc = dispatcher.Acknowledge(
- guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+ AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
- Assert.Equal(0, rc);
- Assert.Equal(guid, consumer.LastAckGuid);
- Assert.Equal("Acked", consumer.LastAckComment);
- Assert.Equal("alice", consumer.LastAckOperatorName);
- Assert.Equal("WS01", consumer.LastAckOperatorNode);
- Assert.Equal("CORP", consumer.LastAckOperatorDomain);
- Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
+ AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
+
+ Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
+ Assert.Equal(guid, consumer.LastResolvedGuid);
+ Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
+ Assert.Equal("checked", writer.LastComment);
+ Assert.Same(op, writer.LastOperator);
}
- /// Verifies that a managed ack client takes GUID acks with full operator identity.
+ /// Verifies an unresolved target short-circuits to its failure without calling the writer.
[Fact]
- public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
+ public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
{
- FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
- FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
+ FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
- consumer,
- new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId,
- managed);
- dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
+ consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
- Guid guid = Guid.NewGuid();
- int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+ AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
- Assert.Equal(0, rc);
- Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
- Assert.Equal(guid, managed.LastAckGuid);
- Assert.Equal("CORP", managed.LastAckOperatorDomain);
- Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
- Assert.Equal(Guid.Empty, consumer.LastAckGuid);
+ Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
+ Assert.Equal("gone", result.Diagnostic);
+ Assert.Null(writer.LastItemAddress);
}
- ///
- /// Verifies that a failed managed-client lifecycle neither fails the alarm
- /// subscription nor leaves acks routed to the broken client.
- ///
+ /// Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.
[Fact]
- public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
+ public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
{
- FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
- FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer
+ {
+ AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
+ };
+ FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
- consumer,
- new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId,
- managed);
+ consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
+ AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
- dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
- Guid guid = Guid.NewGuid();
- int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+ AlarmAckResult result = dispatcher.AcknowledgeByName(
+ "TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
- Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
- Assert.True(managed.Disposed);
- Assert.Equal(0, rc);
- Assert.Equal(guid, consumer.LastAckGuid);
- Assert.Equal(Guid.Empty, managed.LastAckGuid);
+ Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
+ Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
+ Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
+ Assert.Equal("ack", writer.LastComment);
+ Assert.Same(op, writer.LastOperator);
+ }
+
+ /// Verifies a NotAckable by-name target short-circuits without calling the writer.
+ [Fact]
+ public void AcknowledgeByName_WhenNotAckable_DoesNotWrite()
+ {
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotAckable("intouch") };
+ FakeAckWriter writer = new FakeAckWriter();
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
+
+ AlarmAckResult result = dispatcher.AcknowledgeByName("Tag1", "InTouch", "G", "c", new AlarmAckOperator());
+
+ Assert.Equal(AlarmAckOutcome.NotAckable, result.Outcome);
+ Assert.Null(writer.LastItemAddress);
}
///
/// Verifies that while the failover consumer serves the subtag standby,
- /// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client.
+ /// GUID acks resolve through the standby (its synthetic GUIDs).
///
[Fact]
- public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
+ public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
{
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
- FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
+ FakeAlarmConsumer standby = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("Standby.AckMsg") };
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
- FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 };
+ FakeAckWriter writer = new FakeAckWriter();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
failover,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
- managed);
+ writer);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
Guid guid = Guid.NewGuid();
- int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+ dispatcher.Acknowledge(guid, "Acked", new AlarmAckOperator { UserName = "alice" });
- Assert.Equal(0, rc);
- Assert.Equal(guid, standby.LastAckGuid);
- Assert.Equal(Guid.Empty, managed.LastAckGuid);
+ Assert.Equal(guid, standby.LastResolvedGuid);
+ Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
+ Assert.Equal("Standby.AckMsg", writer.LastItemAddress);
}
- /// Verifies that disposing the dispatcher disposes the managed ack client.
+ /// Verifies the dispatcher does not dispose the writer: the handler owns it.
[Fact]
- public void Dispose_WithManagedAckClient_DisposesManagedClient()
+ public void Dispose_DoesNotDisposeAckWriter()
{
- FakeManagedAckClient managed = new FakeManagedAckClient();
+ FakeAckWriter writer = new FakeAckWriter();
AlarmDispatcher dispatcher = new AlarmDispatcher(
new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId,
- managed);
+ writer);
dispatcher.Dispose();
- Assert.True(managed.Disposed);
+ Assert.False(writer.Disposed);
}
- /// Verifies that acknowledge-by-name calls are forwarded to the consumer.
+ /// Verifies the constructor rejects a null ack writer.
[Fact]
- public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
+ public void Constructor_NullAckWriter_Throws()
{
- FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
- using AlarmDispatcher dispatcher = new AlarmDispatcher(
- consumer,
+ Assert.Throws(() => new AlarmDispatcher(
+ new FakeAlarmConsumer(),
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
-
- int rc = dispatcher.AcknowledgeByName(
- alarmName: "TestMachine_001.TestAlarm001",
- providerName: "Galaxy",
- groupName: "TestArea",
- ackComment: "ack",
- ackOperatorName: "alice",
- ackOperatorNode: "WS",
- ackOperatorDomain: "CORP",
- ackOperatorFullName: "Alice Smith");
-
- Assert.Equal(0, rc);
- Assert.NotNull(consumer.LastAckByNameTuple);
- Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
- Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
- Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
+ SessionId,
+ null!));
}
/// Verifies that consumer alarm records are mapped to proto snapshots.
@@ -391,7 +376,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
+ SessionId,
+ new FakeAckWriter());
IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
@@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
+ SessionId,
+ new FakeAckWriter());
IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out _);
Assert.Equal(2, snapshots.Count);
@@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
+ SessionId,
+ new FakeAckWriter());
IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
- SessionId);
+ SessionId,
+ new FakeAckWriter());
IReadOnlyList snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
@@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, new FakeAckWriter());
dispatcher.Dispose();
@@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests
/// Gets the last subscription reference.
public string? LastSubscription { get; private set; }
- /// Gets the GUID from the last acknowledge call.
- public Guid LastAckGuid { get; private set; }
- /// Gets the comment from the last acknowledge call.
- public string? LastAckComment { get; private set; }
- /// Gets the operator name from the last acknowledge call.
- public string? LastAckOperatorName { get; private set; }
- /// Gets the operator node from the last acknowledge call.
- public string? LastAckOperatorNode { get; private set; }
- /// Gets the operator domain from the last acknowledge call.
- public string? LastAckOperatorDomain { get; private set; }
- /// Gets the operator full name from the last acknowledge call.
- public string? LastAckOperatorFullName { get; private set; }
- /// Gets or sets the return code for acknowledge operations.
- public int AcknowledgeReturn { get; set; }
+ /// Gets or sets the ack target both resolve methods return.
+ public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
+ /// Gets the GUID from the last ResolveAckTarget call.
+ public Guid LastResolvedGuid { get; private set; }
+ /// Gets the (name, provider, group) from the last ResolveAckTargetByName call.
+ public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// Gets or sets the result collection for snapshot operations.
public IReadOnlyList SnapshotResult { get; set; } =
Array.Empty();
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
}
///
- public int AcknowledgeByGuid(
- Guid alarmGuid,
- string ackComment,
- string ackOperatorName,
- string ackOperatorNode,
- string ackOperatorDomain,
- string ackOperatorFullName)
+ public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
- LastAckGuid = alarmGuid;
- LastAckComment = ackComment;
- LastAckOperatorName = ackOperatorName;
- LastAckOperatorNode = ackOperatorNode;
- LastAckOperatorDomain = ackOperatorDomain;
- LastAckOperatorFullName = ackOperatorFullName;
- return AcknowledgeReturn;
+ LastResolvedGuid = alarmGuid;
+ return AckTarget;
}
///
- public int AcknowledgeByName(
- string alarmName, string providerName, string groupName,
- string ackComment, string ackOperatorName, string ackOperatorNode,
- string ackOperatorDomain, string ackOperatorFullName)
+ public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
{
- LastAckByNameTuple = (alarmName, providerName, groupName);
- LastAckOperatorName = ackOperatorName;
- return AcknowledgeReturn;
+ LastResolvedName = (alarmName, providerName, groupName);
+ return AckTarget;
}
- /// Gets the last acknowledge-by-name tuple (alarm name, provider, group).
- public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
-
/// Gets or sets the truncation verdict this consumer reports with its snapshot.
public bool SnapshotTruncated { get; set; }
@@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests
}
}
- private sealed class FakeManagedAckClient : IManagedAlarmAckClient
+ private sealed class FakeAckWriter : IAlarmAckWriter
{
- /// Gets the last subscription expression.
- public string? LastSubscription { get; private set; }
- /// Gets the GUID from the last acknowledge call.
- public Guid LastAckGuid { get; private set; }
- /// Gets the operator domain from the last acknowledge call.
- public string? LastAckOperatorDomain { get; private set; }
- /// Gets the operator full name from the last acknowledge call.
- public string? LastAckOperatorFullName { get; private set; }
- /// Gets or sets the return code for acknowledge operations.
- public int AcknowledgeReturn { get; set; }
- /// Gets or sets a value indicating whether throws.
- public bool ThrowOnSubscribe { get; set; }
- /// Gets a value indicating whether this instance has been disposed.
+ public string? LastItemAddress { get; private set; }
+ public string? LastComment { get; private set; }
+ public AlarmAckOperator? LastOperator { get; private set; }
+ public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
public bool Disposed { get; private set; }
- ///
- public void Subscribe(string subscription)
+ public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
- if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
- LastSubscription = subscription;
+ LastItemAddress = itemAddress;
+ LastComment = comment;
+ LastOperator = ackOperator;
+ return Result;
}
- ///
- public int AcknowledgeByGuid(
- Guid alarmGuid,
- string comment,
- string operatorName,
- string operatorNode,
- string operatorDomain,
- string operatorFullName)
- {
- LastAckGuid = alarmGuid;
- LastAckOperatorDomain = operatorDomain;
- LastAckOperatorFullName = operatorFullName;
- return AcknowledgeReturn;
- }
-
- ///
- public void Dispose()
- {
- Disposed = true;
- }
+ public void Dispose() => Disposed = true;
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
index 4659d70..07d378d 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
}
///
- public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
- string operatorNode, string operatorDomain, string operatorFullName)
- => 0;
+ public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
+ => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
///
- public int AcknowledgeByName(string alarmName, string providerName, string groupName,
- string comment, string operatorUser, string operatorNode,
- string operatorDomain, string operatorFullName)
- => 0;
+ public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
+ string comment, AlarmAckOperator ackOperator)
+ => AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
///
public IReadOnlyList QueryActive(
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
deleted file mode 100644
index f21a93f..0000000
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
+++ /dev/null
@@ -1,229 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using System.Linq;
-using System.Threading;
-using ZB.MOM.WW.MxGateway.Contracts.Proto;
-using ZB.MOM.WW.MxGateway.Worker.MxAccess;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
-
-///
-/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
-/// wired into
-/// . Where AlarmsLiveSmokeTests acks by
-/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
-/// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path
-/// that carries operator domain + full name and is not E_NOTIMPL.
-///
-/// Flow: subscribe a wnwrap reader + a managed ack client to
-/// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real
-/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via
-/// the dispatcher (which routes to the managed client); then assert the ack
-/// registers as a subsequent
-/// transition (and that the active snapshot shows the alarm acked).
-///
-/// This is the runtime confirmation the static work could not close: that an
-/// authenticated GUID ack actually flips state. rc=0 alone is "accepted",
-/// not "applied" — the Acknowledge transition is the real assertion. For a
-/// SECURED alarm the ack must run under an operator with Operate permission;
-/// run the dev-rig flip script and, if the test UDA is secured, an
-/// authenticated operator session (see docs/AlarmProbeFindings.md).
-///
-/// Skip-gated; flip Skip=null on the dev rig with AVEVA +
-/// aaAlarmManagedClient.dll present and the flip script running.
-///
-public sealed class AlarmManagedGuidAckLiveSmokeTests
-{
- private static readonly string SubscriptionExpression =
- $@"\\{Environment.MachineName}\Galaxy!DEV";
- private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
- private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30);
- private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30);
-
- private const string SessionId = "alarms-managed-guid-ack-smoke";
-
- private readonly ITestOutputHelper output;
- private readonly Stopwatch elapsed = Stopwatch.StartNew();
- private readonly ConcurrentQueue log = new ConcurrentQueue();
-
- /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.
- /// Test output helper for logging.
- public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
- {
- this.output = output;
- }
-
- /// Acks a live alarm by GUID through the managed client and verifies the acked transition.
- [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")]
- public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge()
- {
- Exception? threadException = null;
- var done = new ManualResetEventSlim(false);
- var thread = new Thread(() =>
- {
- try { RunSmoke(); }
- catch (Exception ex) { threadException = ex; }
- finally { done.Set(); }
- });
- thread.IsBackground = false;
- thread.SetApartmentState(ApartmentState.STA);
- thread.Start();
- done.Wait();
- thread.Join();
-
- output.WriteLine($"Captured {log.Count} log line(s):");
- while (log.TryDequeue(out string? line)) output.WriteLine(line);
-
- if (threadException != null) throw threadException;
- }
-
- private void RunSmoke()
- {
- Log($"Subscription expression: {SubscriptionExpression}");
-
- MxAccessEventQueue queue = new MxAccessEventQueue();
- WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
- new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
- maxAlarmsPerFetch: 1024);
- MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
-
- // Construct the managed ack client directly (production selects it via
- // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1).
- using IManagedAlarmAckClient managedAck =
- new AaManagedAlarmAckConsumer(SessionId + ".ack");
- using AlarmDispatcher dispatcher =
- new AlarmDispatcher(consumer, sink, SessionId, managedAck);
-
- Log("Constructed reader + managed ack client + dispatcher.");
- dispatcher.Subscribe(SubscriptionExpression);
- Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA...");
-
- // 1. Wait for a Raise so there is an active, unacked alarm to ack.
- OnAlarmTransitionEvent? raiseBody = null;
- DateTime raiseDeadline = DateTime.UtcNow + RaiseWait;
- while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
- {
- WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
- if (evt is null) break;
- OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
- Log("Transition: " + DescribeTransition(body));
- Assert.Equal(SessionId, evt.Event.SessionId);
- if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body;
- }
- Assert.NotNull(raiseBody);
-
- // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode).
- // Skip degraded/subtag records — their GUID is synthetic and not
- // valid for AlarmAckByGUID.
- var records = consumer.SnapshotActiveAlarms(out _);
- Log($"Snapshot records: {records.Count}");
- foreach (var r in records)
- Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}");
-
- MxAlarmSnapshotRecord? target = records.FirstOrDefault(r =>
- !r.Degraded &&
- r.AlarmGuid != Guid.Empty &&
- r.State == MxAlarmStateKind.UnackAlm);
- Assert.NotNull(target);
- Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}");
-
- // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with
- // the current Windows identity (domain + full name are carried
- // natively on this path, unlike the wnwrap 6-arg AckByName).
- string user = Environment.UserName;
- string node = Environment.MachineName;
- string domain = Environment.UserDomainName ?? string.Empty;
- Log($"Ack identity user='{user}' node='{node}' domain='{domain}'");
-
- int rc = dispatcher.Acknowledge(
- alarmGuid: target.AlarmGuid,
- ackComment: "managed-guid-ack smoke",
- ackOperatorName: user,
- ackOperatorNode: node,
- ackOperatorDomain: domain,
- ackOperatorFullName: user);
- Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}");
- Assert.Equal(0, rc);
-
- // 4. The ack must APPLY, not merely be accepted: wait for an
- // Acknowledge transition on the same alarm, or the snapshot showing
- // it acked. rc=0 with neither is the inert-ack failure the probe saw.
- bool acked = false;
- DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait;
- while (DateTime.UtcNow < confirmDeadline && !acked)
- {
- WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
- if (evt is not null)
- {
- OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
- Log("Post-ack transition: " + DescribeTransition(body));
- if (body.TransitionKind == AlarmTransitionKind.Acknowledge)
- {
- acked = true;
- break;
- }
- }
-
- // Also accept the active snapshot flipping to an acked state for the
- // target GUID, in case the ack delta was folded without a discrete
- // Acknowledge transition.
- var now = consumer.SnapshotActiveAlarms(out _);
- if (now.Any(r => r.AlarmGuid == target.AlarmGuid &&
- (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName))))
- {
- Log("Snapshot shows target GUID acked (AckAlm / operator populated).");
- acked = true;
- }
- }
-
- Assert.True(
- acked,
- "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " +
- "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " +
- "most likely the alarm's MxSecurityOperate classification requires an authenticated operator).");
- }
-
- private WorkerEvent? WaitForTransition(
- MxAccessEventQueue queue,
- TimeSpan timeout,
- string label,
- WnWrapAlarmConsumer consumer)
- {
- DateTime deadline = DateTime.UtcNow + timeout;
- int pollCount = 0;
- while (DateTime.UtcNow < deadline)
- {
- try
- {
- consumer.PollOnce();
- pollCount++;
- }
- catch (Exception ex)
- {
- Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
- throw;
- }
- if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
- {
- if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt;
- Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
- }
- Thread.Sleep(500);
- }
- Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount}).");
- return null;
- }
-
- private static string DescribeTransition(OnAlarmTransitionEvent body)
- {
- return string.Format(
- "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}",
- body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment,
- body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
- }
-
- private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
-}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
index 181add7..d8c6a3d 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmsLiveSmokeTests.cs
@@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
- using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
+ using AlarmAckWriter ackWriter = new AlarmAckWriter(
+ new MxAccessComObjectFactory(),
+ () => new ZB.MOM.WW.MxGateway.Worker.Sta.StaMessagePump().PumpPendingMessages(),
+ TimeSpan.FromSeconds(5),
+ AlarmAckWriter.DefaultClientName);
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId, ackWriter);
Log("Constructed consumer + sink + dispatcher.");
dispatcher.Subscribe(SubscriptionExpression);
@@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
- // Try the ack with real Windows identity. AVEVA's AlarmAckByName
- // may reject synthetic operator strings; using the current process
- // identity gives the alarm-history a recognizable principal.
- string realUser = Environment.UserName;
- string realNode = Environment.MachineName;
- string realDomain = Environment.UserDomainName ?? string.Empty;
- Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
+ // The ack authenticates a Galaxy operator and writes .AckMsg.
+ // Operator credentials come from the environment (never logged);
+ // the user defaults to the current process identity.
+ AlarmAckOperator ackOperator = new AlarmAckOperator
+ {
+ UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
+ Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
+ };
+ Log($"Ack identity: {ackOperator}");
- int rc = dispatcher.AcknowledgeByName(
- alarmName: alarmName,
- providerName: provider,
- groupName: group,
- ackComment: "alarms-live-smoke ack",
- ackOperatorName: realUser,
- ackOperatorNode: realNode,
- ackOperatorDomain: realDomain,
- ackOperatorFullName: realUser);
- Log($"AcknowledgeByName(real identity) -> rc={rc}");
+ AlarmAckResult result = dispatcher.AcknowledgeByName(
+ alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
+ Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
- Assert.Equal(0, rc);
+ Assert.True(
+ result.Outcome == AlarmAckOutcome.Applied || result.Outcome == AlarmAckOutcome.Unconfirmed,
+ $"Ack outcome {result.Outcome}: {result.Diagnostic}");
// 4. Wait for the post-ack transition. With the alarm flipping every
// 10s and the consumer polling every 500ms, the next state
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
index 44da620..c9afa8d 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
options,
() => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
logger,
stream)
{
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options,
processIdProvider,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
logger: null)
{
}
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call.
_runtimeSession ??= new MxAccessStaSession(
- (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory));
+ (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
IWorkerRuntimeSession session = _runtimeSession;
try
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
deleted file mode 100644
index 8c68f9f..0000000
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
+++ /dev/null
@@ -1,231 +0,0 @@
-using System;
-using System.IO;
-using System.Reflection;
-using WNWRAPCONSUMERLib;
-
-namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
-
-///
-/// Acknowledge path over AVEVA's modern managed alarm client,
-/// aaAlarmManagedClient.AlarmClient (the C++/CLI assembly the AVEVA
-/// Alarm Control itself uses: AlarmClient → CwwAlarmConsumer →
-/// alarmlst.dll → alarmmgr.exe). Preferred over a hand-rolled native
-/// WNAL consumer because AlarmAckByGUID performs the
-/// register → match-by-GUID → submit sequence internally — the record
-/// layout and selection semantics that are provider-defined (and therefore
-/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's
-/// own code. See for why the native
-/// acquisition path is deliberately unfinished.
-///
-/// The AlarmClient members are famorasm (protected-internal)
-/// and mxaccessgw is not an InternalsVisibleTo friend, so everything
-/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is
-/// permitted. Method shapes are confirmed from the assembly metadata:
-/// .ctor() (internal)
-/// int InitializeConsumer(string appName)
-/// int RegisterConsumer(int hWnd, string product, string app, string version, bool)
-/// int Subscribe(string expr, short fromPri, short toPri,
-/// localQueryType, localSortFlags,
-/// localAlarmFilterState mask, localAlarmFilterState spec)
-/// int AlarmAckByGUID(System.Guid guid, string comment, string oper,
-/// string node, string domain, string fullName)
-/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose()
-///
-/// THREADING: the underlying consumer is apartment-affine (same as the
-/// wnwrap COM path). Construct and call this only on the worker STA
-/// (marshal via StaRuntime.InvokeAsync).
-///
-/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
-///
-/// OPEN (confirm at runtime, flagged inline): the trailing bool of
-/// RegisterConsumer (passed false here); whether
-/// Subscribe is actually required before an ack by GUID; and the
-/// native status returned on a real secured ack.
-///
-public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
-{
- /// Default install location of the managed alarm client (eac.dll folder).
- public const string DefaultAssemblyPath =
- @"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
-
- ///
- /// Env var that opts the worker into the managed ack path. Unset/anything
- /// other than "1" keeps the existing wnwrap ack behavior.
- ///
- public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
-
- private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
-
- ///
- /// Production factory for the seam:
- /// returns a managed ack client only when == "1",
- /// otherwise (ack stays on the wnwrap path). Never
- /// throws — a construction failure (missing DLL, etc.) degrades to the
- /// wnwrap path rather than breaking the alarm subscription.
- ///
- public static IManagedAlarmAckClient? EnvGatedFactory()
- {
- if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal))
- return null;
- try
- {
- return new AaManagedAlarmAckConsumer(AckApplicationName);
- }
- catch
- {
- // Opt-in enhancement only: if the managed client can't load, fall
- // back to the wnwrap ack path rather than failing alarm reads.
- return null;
- }
- }
-
- private const string TypeName = "aaAlarmManagedClient.AlarmClient";
- private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
-
- private readonly object syncRoot = new();
- private readonly object client;
- private readonly MethodInfo initializeConsumer;
- private readonly MethodInfo registerConsumer;
- private readonly MethodInfo subscribe;
- private readonly MethodInfo alarmAckByGuid;
- private readonly MethodInfo? deregisterConsumer;
- private readonly MethodInfo? uninitializeConsumer;
-
- private bool subscribed;
- private bool disposed;
-
- /// Consumer application name (e.g. "MxGateway.ack").
- /// Override for .
- public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null)
- {
- if (string.IsNullOrWhiteSpace(applicationName))
- throw new ArgumentException("Application name is required.", nameof(applicationName));
-
- string path = assemblyPath ?? DefaultAssemblyPath;
- if (!File.Exists(path))
- throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path);
-
- Assembly asm = Assembly.LoadFrom(path);
- Type type = asm.GetType(TypeName, throwOnError: true)
- ?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}.");
-
- initializeConsumer = Resolve(type, "InitializeConsumer");
- registerConsumer = Resolve(type, "RegisterConsumer");
- subscribe = Resolve(type, "Subscribe");
- alarmAckByGuid = Resolve(type, "AlarmAckByGUID");
- deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null);
- uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null);
-
- client = Activator.CreateInstance(type, nonPublic: true)
- ?? throw new InvalidOperationException($"Could not construct '{TypeName}'.");
-
- ApplicationName = applicationName;
- }
-
- /// The consumer application name this instance registered under.
- public string ApplicationName { get; }
-
- private static MethodInfo Resolve(Type type, string name) =>
- type.GetMethod(name, Inst)
- ?? throw new MissingMethodException(TypeName, name);
-
- ///
- /// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
- /// proven wnwrap argument values. is the
- /// alarm query root, e.g. \\NODE\Galaxy!Area. Throws if any native
- /// status is non-zero.
- ///
- public void Subscribe(string subscription)
- {
- if (subscription is null) throw new ArgumentNullException(nameof(subscription));
- lock (syncRoot)
- {
- if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
- if (subscribed) throw new InvalidOperationException("Subscribe was already called.");
-
- int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!;
- if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}.");
-
- // RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model.
- // The trailing bool is unconfirmed; false = client/non-modal is the safe default.
- int reg = (int)registerConsumer.Invoke(client, new object[]
- {
- 0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false,
- })!;
- if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}.");
-
- int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!;
- if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}.");
-
- subscribed = true;
- }
- }
-
- ///
- /// Acknowledge a single alarm by GUID with full operator identity. Returns
- /// AVEVA's native status (0 = success). A zero return means accepted, not
- /// necessarily applied — verify the alarm's acked state / Historian row
- /// (see docs/AlarmProbeFindings.md).
- ///
- public int AcknowledgeByGuid(
- Guid alarmGuid,
- string comment,
- string operatorName,
- string operatorNode,
- string operatorDomain,
- string operatorFullName)
- {
- lock (syncRoot)
- {
- if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
- if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging.");
-
- return (int)alarmAckByGuid.Invoke(client, new object[]
- {
- alarmGuid,
- comment ?? string.Empty,
- operatorName ?? string.Empty,
- operatorNode ?? string.Empty,
- operatorDomain ?? string.Empty,
- operatorFullName ?? string.Empty,
- })!;
- }
- }
-
- ///
- /// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
- /// aaAlarmManagedClient's own enum parameter types (localQueryType /
- /// localSortFlags / localAlarmFilterState) via .
- ///
- private object[] BuildSubscribeArgs(string subscription)
- {
- ParameterInfo[] p = subscribe.GetParameters();
- if (p.Length != 7)
- throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7.");
-
- return new object[]
- {
- subscription,
- (short)1, // wFromPri
- (short)999, // wToPri
- Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary),
- Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst),
- Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
- Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
- };
- }
-
- ///
- public void Dispose()
- {
- lock (syncRoot)
- {
- if (disposed) return;
- disposed = true;
-
- try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty