Compare commits

..

2 Commits

Author SHA1 Message Date
Joseph Doherty d6db0ff40d feat(worker): route alarm acks through the operator-authenticated AckMsg writer
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m5s
ci / windows-x86 (push) Successful in 2m10s
ci / portable (push) Failing after 2m17s
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:55 -04:00
Joseph Doherty 0d4b2aea2d refactor(worker): alarm consumers resolve the AckMsg item instead of acking
IMxAccessAlarmConsumer.ResolveAckTarget/ResolveAckTargetByName replace AcknowledgeByGuid/AcknowledgeByName. wnwrap maps Galaxy-provider alarms to <tag>.AckMsg and drops its inert parallel ack consumer; subtag resolves to the target's AckCommentSubtag and no longer advises or writes it; ISubtagAlarmSource.Write is removed. Does not build alone: AlarmDispatcher moves to the new API in the next commit.

Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:35:49 -04:00
25 changed files with 933 additions and 1734 deletions
@@ -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);
}
/// <summary>Verifies that invalid alarm GUID returns invalid request.</summary>
@@ -168,11 +171,14 @@ public sealed class AlarmCommandExecutorTests
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
/// <summary>Verifies that nonzero native status carries a diagnostic message.</summary>
/// <summary>Verifies that a rejected write carries its native status and diagnostic.</summary>
[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);
}
/// <summary>
/// Every ack outcome maps to its protocol status, carries the native
/// status, and never echoes the operator password.
/// </summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[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);
}
/// <summary>The by-name ack maps outcomes identically and never echoes the operator password.</summary>
/// <param name="outcome">The writer outcome.</param>
/// <param name="expected">The expected protocol status.</param>
[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);
}
/// <summary>The writer's OnWriteComplete statuses are copied onto the reply.</summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
@@ -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
/// <summary>Gets the last acknowledge alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledge operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the last acknowledge comment.</summary>
public string? LastComment { get; private set; }
/// <summary>Gets or sets the value returned by acknowledge.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets the last acknowledge operator.</summary>
public AlarmAckOperator? LastOperator { get; private set; }
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
public bool AcknowledgeThrow { get; set; }
/// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
public AlarmAckResult AckResult { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
/// <summary>Gets or sets an exception both acknowledge methods throw.</summary>
public Exception? AcknowledgeException { get; set; }
/// <summary>Gets or sets the query result snapshots.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <summary>Gets the last acknowledge by name tuple.</summary>
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
Assert.False(handler.IsSubscribed);
}
/// <summary>Verifies that acknowledge forwards to consumer with full operator identity when subscribed.</summary>
/// <summary>Verifies that acknowledge resolves via the consumer and writes through the injected writer as the operator.</summary>
[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);
}
/// <summary>Verifies acknowledge-by-name forwards the tuple and a null comment becomes empty.</summary>
[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);
}
/// <summary>Verifies a null operator is rejected.</summary>
[Fact]
public void Acknowledge_NullOperator_Throws()
{
AlarmCommandHandler handler = NewHandlerWithWriter(() => new FakeConsumer(), new RecordingAckWriter());
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
Assert.Throws<ArgumentNullException>(() => handler.Acknowledge(Guid.NewGuid(), "c", null!));
}
/// <summary>
/// The ack writer is created once, on first acknowledgement, survives an
/// unsubscribe/resubscribe cycle, and is disposed with the handler.
/// </summary>
[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);
}
/// <summary>
/// Without a COM factory or writer factory, subscribing still works and
/// the acknowledgement fails with a diagnosable InvalidOperationException.
/// </summary>
[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<InvalidOperationException>(
() => handler.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator()));
Assert.Contains("IMxAccessComObjectFactory", ex.Message);
}
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
() => new FakeConsumer());
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
}
/// <summary>Verifies that query active returns mapped proto snapshots when consumer has alarms.</summary>
@@ -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<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
() => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
Assert.Throws<InvalidOperationException>(
@@ -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));
}
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
@@ -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));
}
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
[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));
}
/// <summary>
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
}
private static AlarmCommandHandler NewHandlerWithWriter(Func<IMxAccessAlarmConsumer> consumerFactory, IAlarmAckWriter writer) =>
new AlarmCommandHandler(
new MxAccessEventQueue(),
consumerFactory,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: null,
ackWriterFactory: () => writer);
/// <summary>Acks through the handler and returns the item the active consumer resolved (the fake writer echoes it as the diagnostic).</summary>
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
/// <summary>Gets the last subscription request.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the last acknowledged alarm GUID.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the last acknowledged operator name.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the snapshot result to return.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <summary>Gets the last acknowledge-by-name parameters.</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
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;
}
}
@@ -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);
}
/// <summary>Verifies that acknowledge calls are forwarded to the consumer with operator identity.</summary>
/// <summary>Verifies the dispatcher resolves the ack item via the consumer then writes it as the operator.</summary>
[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);
}
/// <summary>Verifies that a managed ack client takes GUID acks with full operator identity.</summary>
/// <summary>Verifies an unresolved target short-circuits to its failure without calling the writer.</summary>
[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);
}
/// <summary>
/// Verifies that a failed managed-client lifecycle neither fails the alarm
/// subscription nor leaves acks routed to the broken client.
/// </summary>
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
[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);
}
/// <summary>Verifies a NotAckable by-name target short-circuits without calling the writer.</summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>Verifies that disposing the dispatcher disposes the managed ack client.</summary>
/// <summary>Verifies the dispatcher does not dispose the writer: the handler owns it.</summary>
[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);
}
/// <summary>Verifies that acknowledge-by-name calls are forwarded to the consumer.</summary>
/// <summary>Verifies the constructor rejects a null ack writer.</summary>
[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<ArgumentNullException>(() => 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!));
}
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
@@ -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<ActiveAlarmSnapshot> 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<ActiveAlarmSnapshot> 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<ActiveAlarmSnapshot> 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<ActiveAlarmSnapshot> 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
/// <summary>Gets the last subscription reference.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the comment from the last acknowledge call.</summary>
public string? LastAckComment { get; private set; }
/// <summary>Gets the operator name from the last acknowledge call.</summary>
public string? LastAckOperatorName { get; private set; }
/// <summary>Gets the operator node from the last acknowledge call.</summary>
public string? LastAckOperatorNode { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
public Guid LastResolvedGuid { get; private set; }
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <summary>Gets the last acknowledge-by-name tuple (alarm name, provider, group).</summary>
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
public bool SnapshotTruncated { get; set; }
@@ -664,49 +627,22 @@ public sealed class AlarmDispatcherTests
}
}
private sealed class FakeManagedAckClient : IManagedAlarmAckClient
private sealed class FakeAckWriter : IAlarmAckWriter
{
/// <summary>Gets the last subscription expression.</summary>
public string? LastSubscription { get; private set; }
/// <summary>Gets the GUID from the last acknowledge call.</summary>
public Guid LastAckGuid { get; private set; }
/// <summary>Gets the operator domain from the last acknowledge call.</summary>
public string? LastAckOperatorDomain { get; private set; }
/// <summary>Gets the operator full name from the last acknowledge call.</summary>
public string? LastAckOperatorFullName { get; private set; }
/// <summary>Gets or sets the return code for acknowledge operations.</summary>
public int AcknowledgeReturn { get; set; }
/// <summary>Gets or sets a value indicating whether <see cref="Subscribe"/> throws.</summary>
public bool ThrowOnSubscribe { get; set; }
/// <summary>Gets a value indicating whether this instance has been disposed.</summary>
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; }
/// <inheritdoc />
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;
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorDomain = operatorDomain;
LastAckOperatorFullName = operatorFullName;
return AcknowledgeReturn;
}
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
}
public void Dispose() => Disposed = true;
}
}
@@ -72,10 +72,10 @@ public sealed class FailoverAlarmConsumerTests
}
/// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 11;
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("primary.guid.AckMsg");
/// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 11;
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("primary.name.AckMsg");
/// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -122,10 +122,10 @@ public sealed class FailoverAlarmConsumerTests
public void PollOnce() { }
/// <inheritdoc />
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 22;
public AlarmAckTarget ResolveAckTarget(Guid g) => AlarmAckTarget.For("standby.guid.AckMsg");
/// <inheritdoc />
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 22;
public AlarmAckTarget ResolveAckTargetByName(string n, string p, string gr) => AlarmAckTarget.For("standby.name.AckMsg");
/// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms(out bool truncated)
@@ -305,9 +305,9 @@ public sealed class FailoverAlarmConsumerTests
Assert.Equal(subscribeCountAfterSetup, primary.SubscribeCount);
}
/// <summary>Proves that acknowledgment calls delegate to whichever child (primary or standby) is currently active.</summary>
/// <summary>Proves that ack-target resolution delegates to whichever child (primary or standby) is currently active.</summary>
[Fact]
public void Acknowledge_DelegatesToActiveChild()
public void ResolveAckTarget_DelegatesToActiveChild()
{
FlakyPrimary primary = new FlakyPrimary { ThrowOnPoll = false };
StubStandby standby = new StubStandby();
@@ -316,18 +316,18 @@ public sealed class FailoverAlarmConsumerTests
sut.Subscribe(@"\\HOST\Galaxy!Area");
// Active = Primary → primary's sentinel value (11).
Assert.Equal(11, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
Assert.Equal(11, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
// Active = Primary → primary's sentinel targets.
Assert.Equal("primary.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal("primary.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
// Force a failover by failing the primary past threshold.
primary.ThrowOnPoll = true;
sut.PollOnce(); // threshold=1 → switch to Standby
Assert.Equal(AlarmProviderMode.Subtag, sut.Mode);
// Active = Standby → standby's sentinel value (22).
Assert.Equal(22, sut.AcknowledgeByGuid(Guid.NewGuid(), "c", "n", "node", "dom", "full"));
Assert.Equal(22, sut.AcknowledgeByName("a", "p", "g", "c", "n", "node", "dom", "full"));
// Active = Standby → standby's sentinel targets.
Assert.Equal("standby.guid.AckMsg", sut.ResolveAckTarget(Guid.NewGuid()).ItemAddress);
Assert.Equal("standby.name.AckMsg", sut.ResolveAckTargetByName("a", "p", "g").ItemAddress);
}
/// <summary>
@@ -94,76 +94,28 @@ public sealed class LmxSubtagAlarmSourceTests
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> adds the item when
/// it was not previously advised and writes with user id 0.
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises and
/// removes every advised handle and unregisters the server exactly once.
/// </summary>
[Fact]
public void Write_AddsItemWhenUnknownAndWrites()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Write("Tank1.Alarm.AckComment", "acknowledged");
Assert.Contains("Tank1.Alarm.AckComment", server.AddedItems);
Assert.Single(server.Writes);
RecordingMxAccessServer.WriteRecord write = server.Writes[0];
Assert.Equal(FakeServerHandle, write.ServerHandle);
Assert.Equal("acknowledged", write.Value);
Assert.Equal(0, write.UserId);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Write"/> reuses an existing
/// item handle (no duplicate AddItem) when the address was already
/// advised.
/// </summary>
[Fact]
public void Write_ReusesHandleForAdvisedAddress()
{
var server = new RecordingMxAccessServer();
using var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag" });
int adviseAddCount = server.AddedItems.Count;
source.Write("Tank1.Alarm.Subtag", true);
// No second AddItem for the already-advised address.
Assert.Equal(adviseAddCount, server.AddedItems.Count);
Assert.Single(server.Writes);
}
/// <summary>
/// Verifies <see cref="LmxSubtagAlarmSource.Dispose"/> UnAdvises only the
/// handles that were actually advised — a write-only item (added by
/// <see cref="LmxSubtagAlarmSource.Write"/> but never advised) is removed
/// but not unadvised — and unregisters the server exactly once.
/// </summary>
[Fact]
public void Dispose_UnAdvisesOnlyAdvisedHandles_RemovesAll_AndUnregistersOnce()
public void Dispose_UnAdvisesAndRemovesAdvisedHandles_AndUnregistersOnce()
{
var server = new RecordingMxAccessServer();
var source = new LmxSubtagAlarmSource(server, FakeServerHandle);
source.Advise(new[] { "Tank1.Alarm.Subtag", "Tank2.Alarm.Subtag" });
// A write-only subtag: added by Write, never advised.
source.Write("Tank1.Alarm.AckComment", "acknowledged");
int advised1 = server.LastItemHandleFor("Tank1.Alarm.Subtag");
int advised2 = server.LastItemHandleFor("Tank2.Alarm.Subtag");
int writeOnly = server.LastItemHandleFor("Tank1.Alarm.AckComment");
source.Dispose();
// Only the two advised handles are unadvised — never the write-only one.
Assert.Equal(new[] { advised1, advised2 }, server.UnAdvisedItemHandles);
Assert.DoesNotContain(writeOnly, server.UnAdvisedItemHandles);
// Every added item (advised + write-only) is removed.
Assert.Equal(
new[] { advised1, advised2, writeOnly }.OrderBy(h => h),
new[] { advised1, advised2 }.OrderBy(h => h),
server.RemovedItemHandles.OrderBy(h => h));
Assert.Equal(1, server.UnregisterCount);
Assert.Empty(server.Writes);
}
/// <summary>
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
}
/// <inheritdoc />
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);
/// <inheritdoc />
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);
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="SubtagAlarmConsumer"/>: prove that the subtag
/// fallback advises the observable subtags, synthesizes degraded
/// transitions with stable synthetic GUIDs, routes acknowledgments to the
/// transitions with stable synthetic GUIDs, resolves acknowledgments to the
/// ack-comment subtag, and stamps snapshots. A <see cref="FakeSource"/>
/// stands in for the live MXAccess subtag source so this needs no AVEVA
/// install.
@@ -40,9 +40,13 @@ public sealed class SubtagAlarmConsumerTests
return new SubtagAlarmConsumer(source, new[] { BuildTarget() });
}
/// <summary>Verifies Subscribe advises the active, acked, priority, and ack-comment subtags (the ack-comment is advised so it is an active, writable MXAccess item).</summary>
/// <summary>
/// Verifies Subscribe advises the observable active, acked and priority
/// subtags but not the ack-comment subtag: the ack is written by
/// <see cref="AlarmAckWriter"/>, which adds and advises it on its own server.
/// </summary>
[Fact]
public void Subscribe_AdvisesAllSubtagsIncludingAckComment()
public void Subscribe_AdvisesObservableSubtagsButNotAckComment()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -52,7 +56,7 @@ public sealed class SubtagAlarmConsumerTests
Assert.Contains(ActiveSubtag, source.Advised);
Assert.Contains(AckedSubtag, source.Advised);
Assert.Contains(PrioritySubtag, source.Advised);
Assert.Contains(AckCommentSubtag, source.Advised);
Assert.DoesNotContain(AckCommentSubtag, source.Advised);
}
/// <summary>Verifies an active=true value change raises a degraded, GUID-stamped UNACK_ALM transition.</summary>
@@ -74,50 +78,49 @@ public sealed class SubtagAlarmConsumerTests
Assert.NotEqual(Guid.Empty, emitted.Record.AlarmGuid);
}
/// <summary>Verifies AcknowledgeByName writes the comment to the ack-comment subtag and returns success.</summary>
/// <summary>Verifies ResolveAckTargetByName resolves to the target's ack-comment subtag.</summary>
[Fact]
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
public void ResolveAckTargetByName_ReturnsAckCommentSubtag()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
AlarmAckTarget target = consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea");
Assert.Equal(0, rc);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("operator ack", source.LastWrite!.Value.Value);
Assert.True(target.Found);
Assert.Equal(AckCommentSubtag, target.ItemAddress);
}
/// <summary>Verifies AcknowledgeByName returns non-zero when no target matches the supplied name.</summary>
/// <summary>Verifies ResolveAckTargetByName reports AlarmNotFound when no target matches the supplied name.</summary>
[Fact]
public void AcknowledgeByName_UnknownAlarm_ReturnsNonZero()
public void ResolveAckTargetByName_UnknownAlarm_IsNotFound()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByName(
alarmName: "DoesNotExist.NoSuchAlarm",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "operator ack",
ackOperatorName: "alice",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
AlarmAckTarget target = consumer.ResolveAckTargetByName("DoesNotExist.NoSuchAlarm", "Galaxy", "TestArea");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
Assert.False(target.Found);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, target.Failure);
}
/// <summary>Verifies a target with no ack-comment subtag resolves as NotAckable, by name and by GUID.</summary>
[Fact]
public void ResolveAckTarget_EmptyAckCommentSubtag_IsNotAckable()
{
AlarmSubtagTarget noAck = BuildTarget();
noAck.AckCommentSubtag = string.Empty;
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, new[] { noAck });
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "TestArea").Failure);
Assert.Equal(
AlarmAckOutcome.NotAckable,
consumer.ResolveAckTarget(SyntheticAlarmGuid.ForReference(Reference)).Failure);
}
/// <summary>Verifies a snapshot of an active alarm stamps Degraded and a non-empty synthetic GUID.</summary>
@@ -160,11 +163,11 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>
/// Verifies that when two alarm targets share a prefix (e.g. Level.Hi vs Level.HiHi),
/// AcknowledgeByName routes each ack to its own ack-comment subtag and never
/// ResolveAckTargetByName resolves each to its own ack-comment subtag and never
/// conflates the shorter name with the longer one.
/// </summary>
[Fact]
public void AcknowledgeByName_PrefixNameDoesNotFalseMatch()
public void ResolveAckTargetByName_PrefixNameDoesNotFalseMatch()
{
const string ReferenceHi = "Galaxy!Area.Tank01.Level.Hi";
const string ReferenceHiHi = "Galaxy!Area.Tank01.Level.HiHi";
@@ -195,42 +198,19 @@ public sealed class SubtagAlarmConsumerTests
source, new[] { targetHi, targetHiHi });
consumer.Subscribe(@"\\HOST\Galaxy!Area");
// Ack the shorter name — must write to the shorter target's subtag only.
int rcHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.Hi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hi", source.LastWrite.Value.Value);
// Ack the longer name — must write to the longer target's subtag.
int rcHiHi = consumer.AcknowledgeByName(
alarmName: "Tank01.Level.HiHi",
providerName: "Galaxy",
groupName: "Area",
ackComment: "ack hihi",
ackOperatorName: "op", ackOperatorNode: "WS01",
ackOperatorDomain: "CORP", ackOperatorFullName: "Operator");
Assert.Equal(0, rcHiHi);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentHiHi, source.LastWrite!.Value.Address);
Assert.Equal("ack hihi", source.LastWrite.Value.Value);
Assert.Equal(AckCommentHi, consumer.ResolveAckTargetByName("Tank01.Level.Hi", "Galaxy", "Area").ItemAddress);
Assert.Equal(AckCommentHiHi, consumer.ResolveAckTargetByName("Tank01.Level.HiHi", "Galaxy", "Area").ItemAddress);
Assert.Equal(
AlarmAckOutcome.AlarmNotFound,
consumer.ResolveAckTargetByName("Tank01.Level.H", "Galaxy", "Area").Failure);
}
/// <summary>
/// Verifies AcknowledgeByGuid resolves the synthetic GUID (computed from
/// the alarm's full reference) to the correct target and writes the comment
/// to that target's ack-comment subtag.
/// Verifies ResolveAckTarget resolves the synthetic GUID (computed from
/// the alarm's full reference) to that target's ack-comment subtag.
/// </summary>
[Fact]
public void AcknowledgeByGuid_WritesCommentToAckCommentSubtag()
public void ResolveAckTarget_SyntheticGuid_ReturnsAckCommentSubtag()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
@@ -246,41 +226,21 @@ public sealed class SubtagAlarmConsumerTests
Guid syntheticGuid = emitted!.Record.AlarmGuid;
Assert.NotEqual(Guid.Empty, syntheticGuid);
int rc = consumer.AcknowledgeByGuid(
alarmGuid: syntheticGuid,
ackComment: "guid ack",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
AlarmAckTarget target = consumer.ResolveAckTarget(syntheticGuid);
Assert.Equal(0, rc);
Assert.NotNull(source.LastWrite);
Assert.Equal(AckCommentSubtag, source.LastWrite!.Value.Address);
Assert.Equal("guid ack", source.LastWrite.Value.Value);
Assert.True(target.Found);
Assert.Equal(AckCommentSubtag, target.ItemAddress);
}
/// <summary>
/// Verifies AcknowledgeByGuid returns non-zero and performs no write when
/// the supplied GUID is not known to the consumer.
/// </summary>
/// <summary>Verifies ResolveAckTarget reports AlarmNotFound for a GUID unknown to the consumer.</summary>
[Fact]
public void AcknowledgeByGuid_UnknownGuid_ReturnsNonZero()
public void ResolveAckTarget_UnknownGuid_IsNotFound()
{
FakeSource source = new FakeSource();
using SubtagAlarmConsumer consumer = BuildConsumer(source);
consumer.Subscribe(@"\\HOST\Galaxy!TestArea");
int rc = consumer.AcknowledgeByGuid(
alarmGuid: Guid.NewGuid(),
ackComment: "should not write",
ackOperatorName: "op",
ackOperatorNode: "WS01",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Operator");
Assert.NotEqual(0, rc);
Assert.Null(source.LastWrite);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
private sealed class FakeSource : ISubtagAlarmSource
@@ -291,21 +251,12 @@ public sealed class SubtagAlarmConsumerTests
/// <summary>Gets the subtag addresses passed to <see cref="Advise"/>.</summary>
public List<string> Advised { get; } = new List<string>();
/// <summary>Gets the most recent (address, value) pair passed to <see cref="Write"/>.</summary>
public (string Address, object? Value)? LastWrite { get; private set; }
/// <inheritdoc />
public void Advise(IReadOnlyCollection<string> itemAddresses)
{
Advised.AddRange(itemAddresses);
}
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
LastWrite = (itemAddress, value);
}
/// <summary>Raises a <see cref="SubtagValueChange"/> for the given subtag.</summary>
/// <param name="address">The subtag address whose value changed.</param>
/// <param name="value">The new value.</param>
@@ -824,4 +824,58 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment);
}
/// <summary>A Galaxy-provider record resolves to its <c>&lt;tag&gt;.AckMsg</c> item; an unknown GUID is not found.</summary>
[Fact]
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
});
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
Assert.True(target.Found);
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
}
/// <summary>A non-Galaxy provider has no MXAccess ack attribute, so it is not ackable.</summary>
[Fact]
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
{
Guid guid = Guid.NewGuid();
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
{
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
});
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
}
/// <summary>By-name resolution needs no snapshot: a Galaxy name maps straight to its AckMsg item.</summary>
[Fact]
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
{
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
}
private static WnWrapAlarmConsumer CreateConsumerWithSnapshot(params MxAlarmSnapshotRecord[] records)
{
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(1024);
Dictionary<Guid, MxAlarmSnapshotRecord> next = new Dictionary<Guid, MxAlarmSnapshotRecord>();
foreach (MxAlarmSnapshotRecord record in records)
{
next[record.AlarmGuid] = record;
}
consumer.FoldFetch(next, truncated: false, out _);
return consumer;
}
}
@@ -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;
/// <summary>
/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
/// <see cref="AaManagedAlarmAckConsumer"/> wired into
/// <see cref="AlarmDispatcher"/>. Where <c>AlarmsLiveSmokeTests</c> acks by
/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
/// modern <c>aaAlarmManagedClient.AlarmClient.AlarmAckByGUID</c> — the path
/// that carries operator domain + full name and is not <c>E_NOTIMPL</c>.
///
/// Flow: subscribe a wnwrap reader + a managed ack client to
/// <c>\\&lt;machine&gt;\Galaxy!DEV</c>; 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 <see cref="AlarmTransitionKind.Acknowledge"/>
/// 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 <c>Skip=null</c> on the dev rig with AVEVA +
/// aaAlarmManagedClient.dll present and the flip script running.
/// </summary>
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<string> log = new ConcurrentQueue<string>();
/// <summary>Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.</summary>
/// <param name="output">Test output helper for logging.</param>
public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
{
this.output = output;
}
/// <summary>Acks a live alarm by GUID through the managed client and verifies the acked transition.</summary>
[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}");
}
@@ -2,7 +2,7 @@
//
// Validates the subtag-fallback pipeline against a real Galaxy + MXAccess install:
// LmxSubtagAlarmSource (own LMXProxyServerClass) ->
// SubtagAlarmConsumer (state machine + AcknowledgeByName write) ->
// SubtagAlarmConsumer (state machine + ResolveAckTargetByName) + AlarmAckWriter (AckMsg write) ->
// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid)
//
// FIELD NAMES CONFIRMED: InAlarm/Acked/AckMsg/Priority are the confirmed AVEVA
@@ -49,8 +49,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
/// synthesize <see cref="MxAlarmTransitionEvent"/> records from Galaxy
/// alarm subtags using the confirmed AVEVA <c>AlarmExtension</c> field
/// names (InAlarm/Acked/AckMsg/Priority), and that
/// <see cref="SubtagAlarmConsumer.AcknowledgeByName"/> writes the
/// ack-comment subtag (AckMsg) successfully.
/// <see cref="SubtagAlarmConsumer.ResolveAckTargetByName"/> resolves the
/// ack-comment subtag (AckMsg) that <see cref="AlarmAckWriter"/> then
/// writes successfully.
///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with an alarm being
/// driven (flip script or a manual operator/IDE toggle of the alarm
@@ -119,7 +120,7 @@ public sealed class AlarmSubtagLiveSmokeTests
/// real <c>LMXProxyServerClass</c>, collects a Raise then a Clear
/// transition synthesized by <see cref="SubtagAlarmConsumer"/>, confirms
/// the Degraded flag and synthetic GUID are stamped, then
/// AcknowledgeByName and verifies the ack-comment write returns 0.
/// acknowledges through the resolved ack-comment subtag and verifies the write succeeds.
/// </summary>
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm being driven (flip script or manual operator/IDE toggle of the alarm attribute). Subtag fallback path. Field names confirmed (InAlarm/Acked/AckMsg/Priority); live-validate runtime path resolves without intermediate alarm-condition segment.")]
public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges()
@@ -257,17 +258,9 @@ public sealed class AlarmSubtagLiveSmokeTests
Log("InAlarm subtag delivered a value: " + inAlarmResolved);
// Decisive write: AckMsg. Returns 0 only if the address resolves and is writable.
Log("AcknowledgeByName (writes AckMsg subtag) ...");
int rc = consumer.AcknowledgeByName(
alarmName: AlarmTagName,
providerName: Provider,
groupName: Group,
ackComment: "subtag-resolution-probe",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log(string.Format("AcknowledgeByName(AckMsg write) -> rc={0}", rc));
Log("Acknowledge (writes AckMsg subtag via AlarmAckWriter) ...");
int rc = AckViaWriter(consumer, "subtag-resolution-probe").NativeStatus;
Log(string.Format("Ack (AckMsg write) -> rc={0}", rc));
DateTime settle = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < settle)
@@ -392,21 +385,13 @@ public sealed class AlarmSubtagLiveSmokeTests
Assert.True(foundInSnapshot,
string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference));
// 3. AcknowledgeByName — writes the ack-comment subtag.
// 3. Resolve the ack-comment subtag and write it as the operator.
// The dispatcher derives (alarmName, provider, group) via the same
// TryParseReference logic as AlarmsLiveSmokeTests.
Log("Calling AcknowledgeByName ...");
int rc = consumer.AcknowledgeByName(
alarmName: AlarmTagName,
providerName: Provider,
groupName: Group,
ackComment: "subtag-fallback-smoke ack",
ackOperatorName: Environment.UserName,
ackOperatorNode: Environment.MachineName,
ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
ackOperatorFullName: Environment.UserName);
Log("Acknowledging via AlarmAckWriter ...");
int rc = AckViaWriter(consumer, "subtag-fallback-smoke ack").NativeStatus;
Log(string.Format("AcknowledgeByName -> rc={0}", rc));
Log(string.Format("Ack -> rc={0}", rc));
Assert.Equal(0, rc);
// 4. Wait for a Clear or Acknowledge transition to confirm the state
@@ -501,6 +486,29 @@ public sealed class AlarmSubtagLiveSmokeTests
/// Mirrors the pump pattern documented in
/// <c>docs/MxAccessWorkerInstanceDesign.md</c>.
/// </summary>
/// <summary>
/// Resolves the probe alarm's ack item through the consumer and writes it
/// with <see cref="AlarmAckWriter"/> as the operator named by
/// <c>MXGATEWAY_PROBE_ACK_USER</c>/<c>MXGATEWAY_PROBE_ACK_PASSWORD</c>
/// (default: current user, empty password).
/// </summary>
private static AlarmAckResult AckViaWriter(SubtagAlarmConsumer consumer, string comment)
{
AlarmAckTarget target = consumer.ResolveAckTargetByName(AlarmTagName, Provider, Group);
if (!target.Found)
{
return AlarmAckResult.FromTarget(target);
}
using AlarmAckWriter writer = new AlarmAckWriter(
new MxAccessComObjectFactory(), PumpMessages, TimeSpan.FromSeconds(5), AlarmAckWriter.DefaultClientName);
return writer.Acknowledge(target.ItemAddress, comment, new AlarmAckOperator
{
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
});
}
private static void PumpMessages()
{
NativeMethods.MSG msg;
@@ -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 <tag>.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
@@ -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
{
@@ -1,231 +0,0 @@
using System;
using System.IO;
using System.Reflection;
using WNWRAPCONSUMERLib;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledge path over AVEVA's modern managed alarm client,
/// <c>aaAlarmManagedClient.AlarmClient</c> (the C++/CLI assembly the AVEVA
/// Alarm Control itself uses: <c>AlarmClient → CwwAlarmConsumer →
/// alarmlst.dll → alarmmgr.exe</c>). Preferred over a hand-rolled native
/// WNAL consumer because <c>AlarmAckByGUID</c> 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 <see cref="WnalNativeAckConsumer"/> for why the native
/// acquisition path is deliberately unfinished.
///
/// The <c>AlarmClient</c> members are <c>famorasm</c> (protected-internal)
/// and mxaccessgw is not an <c>InternalsVisibleTo</c> 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 <c>StaRuntime.InvokeAsync</c>).
///
/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
///
/// OPEN (confirm at runtime, flagged inline): the trailing <c>bool</c> of
/// <c>RegisterConsumer</c> (passed <c>false</c> here); whether
/// <c>Subscribe</c> is actually required before an ack by GUID; and the
/// native status returned on a real secured ack.
/// </summary>
public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
{
/// <summary>Default install location of the managed alarm client (eac.dll folder).</summary>
public const string DefaultAssemblyPath =
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
/// <summary>
/// Env var that opts the worker into the managed ack path. Unset/anything
/// other than "1" keeps the existing wnwrap ack behavior.
/// </summary>
public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
/// <summary>
/// Production factory for the <see cref="AlarmCommandHandler"/> seam:
/// returns a managed ack client only when <see cref="EnableEnvVar"/> == "1",
/// otherwise <see langword="null"/> (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.
/// </summary>
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;
/// <param name="applicationName">Consumer application name (e.g. "MxGateway.ack").</param>
/// <param name="assemblyPath">Override for <see cref="DefaultAssemblyPath"/>.</param>
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;
}
/// <summary>The consumer application name this instance registered under.</summary>
public string ApplicationName { get; }
private static MethodInfo Resolve(Type type, string name) =>
type.GetMethod(name, Inst)
?? throw new MissingMethodException(TypeName, name);
/// <summary>
/// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
/// proven wnwrap argument values. <paramref name="subscription"/> is the
/// alarm query root, e.g. <c>\\NODE\Galaxy!Area</c>. Throws if any native
/// status is non-zero.
/// </summary>
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;
}
}
/// <summary>
/// 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).
/// </summary>
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,
})!;
}
}
/// <summary>
/// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
/// aaAlarmManagedClient's own enum parameter types (localQueryType /
/// localSortFlags / localAlarmFilterState) via <see cref="Enum.ToObject(Type,object)"/>.
/// </summary>
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),
};
}
/// <inheritdoc />
public void Dispose()
{
lock (syncRoot)
{
if (disposed) return;
disposed = true;
try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
try { uninitializeConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
(client as IDisposable)?.Dispose();
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object();
private readonly IAlarmAckWriter deferredAckWriter;
private AlarmDispatcher? dispatcher;
private IAlarmAckWriter? ackWriter;
private FailoverAlarmConsumer? failoverConsumer;
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
private string subscribeSessionId = string.Empty;
@@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
/// live COM factory.
/// </param>
/// <param name="managedAckFactory">
/// Optional per-subscription factory for a managed ack client
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
/// client, GUID-keyed acknowledges route through AVEVA's modern
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
/// <param name="ackWriterFactory">
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
/// <paramref name="comFactory"/> that registers its own MXAccess server
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
/// The writer is created once per handler, on the first acknowledgement
/// (so a subscription never fails because ack is unavailable), survives
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
/// inject a fake.
/// </param>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
Func<IAlarmAckWriter>? ackWriterFactory = null)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck;
this.comFactory = comFactory;
this.standbyFactory = standbyFactory;
this.managedAckFactory = managedAckFactory;
this.ackWriterFactory = ackWriterFactory;
this.deferredAckWriter = new DeferredAckWriter(this);
}
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
failover.ProviderModeChanged += providerModeChangedHandler;
}
// Optional managed ack client (opt-in via env); null → wnwrap ack.
// A forced-subtag session never has alarmmgr GUIDs to ack, so it
// skips the extra managed consumer entirely.
IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag
? null
: managedAckFactory?.Invoke();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
try
{
dispatcher.Subscribe(subscriptionExpression);
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
AlarmDispatcher d = GetDispatcherOrThrow();
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
}
/// <inheritdoc />
public int AcknowledgeByName(
public AlarmAckResult AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
ackOperator);
}
/// <inheritdoc />
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
d?.PollOnce();
}
private IAlarmAckWriter GetOrCreateAckWriter()
{
lock (syncRoot)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (ackWriter is not null) return ackWriter;
if (ackWriterFactory is not null)
{
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
return ackWriter;
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
}
// StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on
// the calling thread — the STA, since acks run there — which is what
// delivers the writer's OnWriteComplete callback.
ackWriter = new AlarmAckWriter(
comFactory,
() => new StaMessagePump().PumpPendingMessages(),
MxAccessStaSession.ResolveWriteCompletionTimeout(),
AlarmAckWriter.ResolveClientName());
return ackWriter;
}
}
/// <summary>
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
/// handler's real writer on first acknowledgement so the writer (and its
/// MXAccess server registration) is created only when an ack happens and
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
/// the real writer.
/// </summary>
private sealed class DeferredAckWriter : IAlarmAckWriter
{
private readonly AlarmCommandHandler owner;
public DeferredAckWriter(AlarmCommandHandler owner)
{
this.owner = owner;
}
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
public void Dispose()
{
}
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
if (disposed) return;
disposed = true;
Unsubscribe();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
}
}
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
/// through <see cref="AlarmCommandHandler"/>, which owns one
/// dispatcher per session.
/// dispatcher per session. Acknowledgement resolves the alarm to its
/// MXAccess ack item through the consumer and hands the write to the
/// <see cref="IAlarmAckWriter"/>, which authenticates as the operator.
/// </para>
/// <para>
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
@@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler;
private IManagedAlarmAckClient? managedAck;
private readonly IAlarmAckWriter ackWriter;
private bool disposed;
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary>
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer.</summary>
/// <param name="consumer">The alarm consumer.</param>
/// <param name="sink">The alarm event sink.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="managedAck">
/// Optional managed ack client. When non-null, GUID-keyed acknowledges
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) instead
/// of the wnwrap consumer while the alarmmgr provider is active; null
/// keeps the existing wnwrap ack path. Subtag-mode acks always stay on
/// the consumer because their GUIDs are synthetic.
/// <param name="ackWriter">
/// Writes the resolved ack item as the authenticated operator. Owned by
/// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
/// dispatcher never does, so the writer's registered MXAccess server
/// outlives an unsubscribe/resubscribe cycle.
/// </param>
public AlarmDispatcher(
IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink,
string sessionId,
IManagedAlarmAckClient? managedAck = null)
IAlarmAckWriter ackWriter)
{
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty;
this.managedAck = managedAck;
this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter));
// Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference
@@ -80,113 +81,37 @@ public sealed class AlarmDispatcher : IDisposable
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
consumer.Subscribe(subscription);
// The managed ack client maintains its own registered/subscribed
// consumer; run its lifecycle against the same expression so a later
// AlarmAckByGUID has a live consumer to act through. It is an opt-in
// enhancement, so a failed lifecycle drops back to the wnwrap ack path
// rather than failing the alarm subscription.
if (managedAck is not null)
{
try
{
managedAck.Subscribe(subscription);
}
catch
{
try { managedAck.Dispose(); } catch { /* swallow */ }
managedAck = null;
}
}
}
/// <summary>
/// True when the consumer is currently serving alarms from the subtag
/// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr.
/// </summary>
private bool IsSubtagActive() =>
consumer is SubtagAlarmConsumer ||
(consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag);
/// <summary>
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success).
/// </summary>
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int Acknowledge(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
/// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
if (managedAck is not null && !IsSubtagActive())
{
// Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries
// operator domain + full name and is not E_NOTIMPL like the wnwrap
// COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are
// synthetic and only the subtag consumer can resolve them (it acks
// via an AckMsg write).
return managedAck.AcknowledgeByGuid(
alarmGuid,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
return consumer.AcknowledgeByGuid(
alarmGuid,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
return target.Found
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
: AlarmAckResult.FromTarget(target);
}
/// <summary>
/// Acknowledge an alarm by its (name, provider, group) tuple.
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
/// <param name="alarmName">The tag/alarm name.</param>
/// <param name="providerName">The provider.</param>
/// <param name="groupName">The group/area.</param>
/// <param name="ackComment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
public AlarmAckResult AcknowledgeByName(
string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByName(
alarmName,
providerName,
groupName,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
return target.Found
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
: AlarmAckResult.FromTarget(target);
}
/// <summary>
@@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
try { sink.Detach(); } catch { /* swallow */ }
try { consumer.Dispose(); } catch { /* swallow */ }
try { managedAck?.Dispose(); } catch { /* swallow */ }
}
}
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> Like its children, this type is driven
/// entirely on the worker's STA: <see cref="Subscribe"/>,
/// <see cref="PollOnce"/>, <see cref="ProbeOnce"/>, and the
/// <c>AcknowledgeBy*</c> calls are all invoked from the apartment that
/// <c>ResolveAckTarget*</c> calls are all invoked from the apartment that
/// owns the underlying COM objects. It owns no locks of its own and no
/// internal timer; the worker drives <see cref="PollOnce"/> on a timer.
/// </para>
@@ -259,34 +259,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByGuid(
alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
return ActiveChild.ResolveAckTarget(alarmGuid);
}
/// <inheritdoc />
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)
{
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
return ActiveChild.AcknowledgeByName(
alarmName, providerName, groupName, ackComment,
ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName);
return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
}
/// <inheritdoc />
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
/// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain name.</param>
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <returns>The acknowledgement outcome.</returns>
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
/// <summary>
/// Acknowledge a single alarm by (name, provider, group) — used when
/// the caller has the human-readable reference but not the GUID.
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
/// <param name="alarmName">The alarm (tag) name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user name.</param>
/// <param name="operatorNode">The operator node name.</param>
/// <param name="operatorDomain">The operator domain name.</param>
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
/// <returns>The acknowledgement outcome.</returns>
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
/// <summary>
/// Snapshot the currently-active alarm set, optionally scoped to a
@@ -1,44 +0,0 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Optional ack-only seam over AVEVA's modern managed alarm client
/// (<c>aaAlarmManagedClient.AlarmClient</c>). When supplied to an
/// <see cref="AlarmDispatcher"/>, GUID-keyed acknowledges route here
/// instead of the wnwrap COM consumer's <c>AlarmAckByGUID</c> (which is
/// <c>E_NOTIMPL</c> on current builds) — carrying full operator identity
/// (domain + full name) that the wnwrap 6-arg <c>AlarmAckByName</c> drops.
/// The production implementation is <see cref="AaManagedAlarmAckConsumer"/>.
/// </summary>
public interface IManagedAlarmAckClient : IDisposable
{
/// <summary>
/// Run the client's Initialize → Register → Subscribe lifecycle against
/// the same subscription expression the read consumer uses
/// (<c>\\&lt;node&gt;\Galaxy!&lt;area&gt;</c>). Must be called once,
/// on the worker STA, before <see cref="AcknowledgeByGuid"/>.
/// </summary>
/// <param name="subscription">The subscription expression.</param>
void Subscribe(string subscription);
/// <summary>
/// Acknowledge a single alarm by GUID with full operator identity.
/// Returns AVEVA's native status (0 = success; 0 means accepted, not
/// necessarily applied — verify the alarm's acked state / Historian row).
/// </summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorName">The operator name.</param>
/// <param name="operatorNode">The operator node.</param>
/// <param name="operatorDomain">The operator domain.</param>
/// <param name="operatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByGuid(
Guid alarmGuid,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName);
}
@@ -49,51 +49,19 @@ public interface IMxAccessAlarmConsumer : IDisposable
void Subscribe(string subscription);
/// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
/// user / node / domain / full-name and the comment land atomically
/// with the ack transition in the alarm-history log.
/// Resolves the MXAccess item whose write acknowledges the alarm with the
/// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
/// </summary>
/// <param name="alarmGuid">The alarm GUID.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
/// <returns>The resolved target or the reason none exists.</returns>
AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
/// <summary>
/// Acknowledge a single alarm by its (name, provider, group) tuple.
/// Reaches AVEVA's <c>AlarmAckByName</c> on
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the
/// human-readable reference but not the canonical GUID.
/// </summary>
/// <param name="alarmName">The alarm name.</param>
/// <param name="providerName">The provider name.</param>
/// <param name="groupName">The group name.</param>
/// <param name="ackComment">The acknowledgment comment.</param>
/// <param name="ackOperatorName">The operator name.</param>
/// <param name="ackOperatorNode">The operator node.</param>
/// <param name="ackOperatorDomain">The operator domain.</param>
/// <param name="ackOperatorFullName">The operator full name.</param>
/// <returns>The AVEVA-native status code.</returns>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
/// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
/// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
/// <param name="groupName">The area/group.</param>
/// <returns>The resolved target or the reason none exists.</returns>
AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
/// <summary>
/// Returns the consumer's most recently parsed snapshot of currently
@@ -16,9 +16,4 @@ public interface ISubtagAlarmSource : IDisposable
/// <summary>Begins advising the supplied MXAccess subtag item addresses.</summary>
/// <param name="itemAddresses">The subtag references to advise.</param>
void Advise(IReadOnlyCollection<string> itemAddresses);
/// <summary>Writes a value to the named MXAccess subtag (e.g. an ack request).</summary>
/// <param name="itemAddress">The subtag reference to write.</param>
/// <param name="value">The value to write.</param>
void Write(string itemAddress, object? value);
}
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// Production <see cref="ISubtagAlarmSource"/> backed by its own MXAccess
/// <c>LMXProxyServerClass</c> COM object. It advises a set of alarm subtag
/// item addresses, normalizes each <c>OnDataChange</c> COM callback into a
/// <see cref="SubtagValueChange"/>, and supports writing a value (e.g. an
/// ack-comment) to a subtag.
/// <see cref="SubtagValueChange"/>. It is observe-only: alarm
/// acknowledgement goes through <see cref="AlarmAckWriter"/>, which
/// authenticates the operator on its own server.
/// </summary>
/// <remarks>
/// <para>
@@ -25,7 +26,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <strong>Threading.</strong> The <c>LMXProxyServerClass</c> CLSID is
/// apartment-threaded and delivers <c>OnDataChange</c> via the STA
/// message pump. Every method on this type — the lazy <see cref="Advise"/>
/// creation, <see cref="Write"/>, and <see cref="Dispose"/> — must be
/// creation and <see cref="Dispose"/> — must be
/// invoked on the worker's STA that created the source, exactly as
/// <see cref="MxAccessSession"/> and <see cref="WnWrapAlarmConsumer"/>
/// require. The <c>OnDataChange</c> COM event also arrives on that STA.
@@ -47,9 +48,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
new Dictionary<int, string>();
// Handles that were actually Advise()d, tracked separately from the added
// set so Dispose only UnAdvises advised items. Write() can AddItem a
// write-only subtag (e.g. an ack-comment that was never advised); calling
// UnAdvise on such a handle would be an unbalanced teardown.
// set so Dispose only UnAdvises items this source advised.
private readonly HashSet<int> advisedItemHandles = new HashSet<int>();
private object? mxAccessComObject;
@@ -85,7 +84,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
/// <see cref="IMxAccessServer"/> with a pre-registered server handle,
/// bypassing the live COM factory. Exposed via
/// <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c> so tests can
/// exercise <see cref="Advise"/>/<see cref="Write"/> and
/// exercise <see cref="Advise"/> and
/// <see cref="HandleDataChange"/> without a live <c>LMXProxyServerClass</c>.
/// No COM event source is wired in this mode — tests drive
/// <see cref="HandleDataChange"/> directly to simulate an
@@ -140,29 +139,6 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
}
}
/// <inheritdoc />
public void Write(string itemAddress, object? value)
{
// Writes with MXAccess user id 0 — always an unsecured Write, never WriteSecured semantics.
if (itemAddress is null)
{
throw new ArgumentNullException(nameof(itemAddress));
}
ThrowIfDisposed();
EnsureRegistered();
IMxAccessServer mxServer = server!;
if (!itemHandlesByAddress.TryGetValue(itemAddress, out int itemHandle))
{
itemHandle = mxServer.AddItem(serverHandle, itemAddress);
itemHandlesByAddress[itemAddress] = itemHandle;
addressesByItemHandle[itemHandle] = itemAddress;
}
mxServer.Write(serverHandle, itemHandle, value, userId: 0);
}
/// <summary>
/// Normalizes an MXAccess <c>OnDataChange</c> callback into a
/// <see cref="SubtagValueChange"/> and raises <see cref="ValueChanged"/>
@@ -223,8 +199,7 @@ public sealed class LmxSubtagAlarmSource : ISubtagAlarmSource
{
foreach (KeyValuePair<int, string> entry in addressesByItemHandle)
{
// Only UnAdvise handles that were actually advised; a write-only
// item (added by Write but never Advise'd) was never advised.
// Only UnAdvise handles that were actually advised.
if (advisedItemHandles.Contains(entry.Key))
{
try { mxServer.UnAdvise(serverHandle, entry.Key); } catch { /* swallow — best effort */ }
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta;
@@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
}
// operator_password is a raw credential: it goes into AlarmAckOperator for
// MXAccess AuthenticateUser and nowhere else. Never format the payload
// (its generated ToString renders every field) or the exception text.
try
{
int rc = alarmCommandHandler.Acknowledge(
AlarmAckResult result = alarmCommandHandler.Acknowledge(
alarmGuid,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
}
return reply;
new AlarmAckOperator
{
UserName = payload.OperatorUser,
Domain = payload.OperatorDomain,
Password = payload.OperatorPassword,
});
return CreateAcknowledgeReply(command, result);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
return CreateAcknowledgeFailureReply(command, ex);
}
}
@@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
}
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
try
{
int rc = alarmCommandHandler.AcknowledgeByName(
AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName,
payload.ProviderName,
payload.GroupName,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
}
return reply;
new AlarmAckOperator
{
UserName = payload.OperatorUser,
Domain = payload.OperatorDomain,
Password = payload.OperatorPassword,
});
return CreateAcknowledgeReply(command, result);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
return CreateAcknowledgeFailureReply(command, ex);
}
}
private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
{
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = result.NativeStatus;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
reply.Statuses.Add(result.Statuses);
reply.DiagnosticMessage = result.Diagnostic;
switch (result.Outcome)
{
case AlarmAckOutcome.AlarmNotFound:
case AlarmAckOutcome.NotAckable:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
break;
case AlarmAckOutcome.AuthenticationFailed:
case AlarmAckOutcome.WriteRejected:
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
break;
}
return reply;
}
/// <summary>
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
/// MXAccess Register failing, or no active subscription) to an
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> reply. Only the
/// exception type and HRESULT are reported: exception text on this path
/// is not under our control and must not be able to echo operator input.
/// </summary>
private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception)
{
string diagnostic = string.Format(
CultureInfo.InvariantCulture,
"Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).",
exception.GetType().FullName,
exception.HResult);
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = exception.HResult,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = diagnostic,
},
DiagnosticMessage = diagnostic,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult },
};
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
@@ -27,9 +27,11 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <para>
/// The subtag path is event-driven, so <see cref="PollOnce"/> is a
/// no-op — value changes arrive through the source's advise stream
/// rather than an explicit poll. Acknowledgment in subtag mode writes
/// the comment to the target's writable ack-comment subtag rather than
/// calling a native <c>AlarmAckByGUID</c> / <c>AlarmAckByName</c>.
/// rather than an explicit poll. Acknowledgment in subtag mode resolves
/// to the target's writable ack-comment subtag
/// (<see cref="ResolveAckTarget"/>); <see cref="AlarmAckWriter"/>
/// performs the operator-authenticated write on its own MXAccess
/// server, so this consumer neither advises nor writes that subtag.
/// </para>
/// </remarks>
public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
@@ -49,7 +51,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// Initializes the consumer over a subtag source and a watch list of
/// alarm targets.
/// </summary>
/// <param name="source">The subtag value source to advise and write through.</param>
/// <param name="source">The subtag value source to advise.</param>
/// <param name="watchList">The alarm subtag targets to observe.</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="source"/> or <paramref name="watchList"/>
@@ -91,7 +93,6 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
AddIfNotEmpty(addresses, target.ActiveSubtag);
AddIfNotEmpty(addresses, target.AckedSubtag);
AddIfNotEmpty(addresses, target.PrioritySubtag);
AddIfNotEmpty(addresses, target.AckCommentSubtag);
}
source.Advise(addresses);
@@ -104,13 +105,7 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed)
{
@@ -120,22 +115,14 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
!targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
{
return -1;
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
}
return WriteAckComment(target, ackComment);
return ToAckTarget(target);
}
/// <inheritdoc />
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)
{
if (disposed)
{
@@ -143,12 +130,9 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
}
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
if (target is null)
{
return -1;
}
return WriteAckComment(target, ackComment);
return target is null
? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
: ToAckTarget(target);
}
/// <inheritdoc />
@@ -260,16 +244,12 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
return target;
}
private int WriteAckComment(AlarmSubtagTarget target, string ackComment)
private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
{
string ackCommentSubtag = target.AckCommentSubtag ?? string.Empty;
if (string.IsNullOrEmpty(ackCommentSubtag))
{
return -1;
}
source.Write(ackCommentSubtag, ackComment);
return 0;
string ackItem = target.AckCommentSubtag ?? string.Empty;
return ackItem.Length == 0
? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
: AlarmAckTarget.For(ackItem);
}
private static void AddIfNotEmpty(List<string> addresses, string? address)
@@ -34,7 +34,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// and operated from an STA thread; the worker's
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
/// The consumer owns <em>no</em> internal timer: every COM call
/// (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must
/// (<c>Subscribe</c>, <c>PollOnce</c>) must
/// be invoked on the STA that created the consumer. Polling cadence
/// is driven externally by the worker's STA via
/// <c>StaRuntime.InvokeAsync(() =&gt; consumer.PollOnce())</c>, which
@@ -43,6 +43,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// off the owning STA and can deadlock on cross-apartment marshaling
/// when the STA is not pumping messages, so no such timer exists.
/// </para>
/// <para>
/// <strong>Acknowledgement.</strong> This consumer only receives
/// alarms. The wnwrap <c>AlarmAckBy*</c> calls were accepted and then
/// inert on the dev rig (see <c>docs/AlarmProbeFindings.md</c>), so
/// acknowledgement no longer goes through wnwrap: the consumer resolves
/// a Galaxy alarm to its <c>&lt;tag&gt;.AckMsg</c> item
/// (<see cref="ResolveAckTarget"/>) and <see cref="AlarmAckWriter"/>
/// writes that item over MXAccess as the authenticated operator.
/// </para>
/// </remarks>
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
{
@@ -50,6 +59,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
private const string DefaultVersion = "1.0";
private const int DefaultMaxAlarmsPerFetch = 1024;
private const string GalaxyProviderName = "Galaxy";
/// <summary>
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
@@ -93,7 +103,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private long truncatedFetchCount;
private bool lastSnapshotTruncated;
private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private bool subscribed;
private bool disposed;
@@ -200,7 +209,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// WIN-911-style code uses against the same wnwrap library.
// Note rc=0 means the call was
// accepted, not that an acknowledgement was applied — see
// AcknowledgeByName below and docs/AlarmProbeFindings.md.
// docs/AlarmProbeFindings.md. Acks now go through AlarmAckWriter.
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
if (init != 0)
{
@@ -241,10 +250,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
// calling it is necessary for subsequent GetXmlCurrentAlarms2
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
// also breaks AlarmAckByName on the same consumer (rejects with
// -55), so a separate ack-only consumer is provisioned below
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
// E_FAIL (HRESULT 0x80004005) on the first poll.
//
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
// — there is no integer return code to gate on like the other v1
@@ -268,116 +274,49 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
ex);
}
// Provision a parallel COM consumer for ack calls. It runs the
// v1 lifecycle (Initialize/Register/Subscribe) only; without
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
// — we never poll this consumer.
ackClient = new wwAlarmConsumerClass();
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName + ".ack",
szVersion: DefaultVersion);
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
{
throw new InvalidOperationException(
$"Ack consumer setup returned non-zero status: " +
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
}
subscribed = true;
}
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
MxAlarmSnapshotRecord? record;
lock (syncRoot)
{
latestSnapshot.TryGetValue(alarmGuid, out record);
}
wwAlarmConsumerClass com = client
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
if (record is null)
{
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
}
// VBGUID is wnwrap's GUID interop struct (same memory layout as
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
// unmanaged-blittable round-trip.
VBGUID vb = ToVbGuid(alarmGuid);
return com.AlarmAckByGUID(
AlmGUID: vb,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty,
szDomainName: ackOperatorDomain ?? string.Empty,
szOprFullName: ackOperatorFullName ?? string.Empty);
return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
}
/// <inheritdoc />
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)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
return ResolveGalaxyAckTarget(providerName, alarmName);
}
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
// empirical justification.
wwAlarmConsumerClass com = ackClient
?? throw new InvalidOperationException(
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
{
if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
{
return AlarmAckTarget.NotAckable(
$"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
}
// The original observation (live dev-rig 2026-05-01) was narrower than
// it read: the IwwAlarmConsumer2 8-arg AlarmAckByName returned -55
// "and looked like a stub" while the legacy 6-arg
// IwwAlarmConsumer.AlarmAckByName returned 0, which is how the 6-arg
// overload came to be the one called here. The 2026-08-18 probe
// corrected both halves. The -55 tracks the *consumer*, not the
// overload: both overloads return -55 against a
// SetXmlAlarmQuery-applied consumer and 0 against the ack-only one
// used above. And a zero return is NOT evidence the acknowledgement
// was applied — the probe acked a real, freshly raised alarm six ways
// and watched the snapshot stay UNACK_ALM, OPERATOR_NAME stay empty,
// and the alarm extension's own .Acked attribute stay False for 16 s
// after each call. On that rig the ack is accepted and then inert, so
// whether any wnwrap ack reaches the alarm-history path is
// unverified; see docs/AlarmProbeFindings.md and the 2026-08-18
// correction in docs/AlarmClientDiscovery.md. The 6-arg call site
// below therefore stays as-is for MXAccess parity — the choice is no
// longer justified by the rc, and rc semantics are per the probe.
//
// Operator-domain and operator-full-name fields are accepted by the
// proto contract for forward-compat but are not propagated to AVEVA
// today — wrapped in the 6-arg call so domain/full-name go to the
// alarm-history operator-name field via the szOprName parameter.
// Suppress unused-warning explicitly:
_ = ackOperatorDomain;
_ = ackOperatorFullName;
return com.AlarmAckByName(
szAlarmName: alarmName ?? string.Empty,
szProviderName: providerName ?? string.Empty,
szGroupName: groupName ?? string.Empty,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty);
if (string.IsNullOrWhiteSpace(tagName))
{
return AlarmAckTarget.NotFound("Alarm record has no tag name.");
}
return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
}
/// <inheritdoc />
@@ -944,37 +883,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
return sb.ToString();
}
private static VBGUID ToVbGuid(Guid g)
{
byte[] bytes = g.ToByteArray();
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
VBGUID vb = new VBGUID
{
Data1 = BitConverter.ToInt32(bytes, 0),
Data2 = BitConverter.ToInt16(bytes, 4),
Data3 = BitConverter.ToInt16(bytes, 6),
Data4 = new byte[8],
};
Array.Copy(bytes, 8, vb.Data4, 0, 8);
return vb;
}
/// <inheritdoc />
public void Dispose()
{
wwAlarmConsumerClass? clientToDispose;
wwAlarmConsumerClass? ackClientToDispose;
lock (syncRoot)
{
if (disposed) return;
disposed = true;
clientToDispose = client;
client = null;
ackClientToDispose = ackClient;
ackClient = null;
}
ReleaseConsumerCom(clientToDispose);
ReleaseConsumerCom(ackClientToDispose);
}
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
@@ -1,273 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Native alarm-acknowledge path over <c>alarmlst.dll</c>'s public <c>WNAL*</c>
/// C exports (the same stack the AVEVA Alarm Control uses:
/// <c>aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe</c>).
///
/// Why this exists alongside <see cref="WnWrapAlarmConsumer"/>: the legacy
/// <c>WNWRAPCONSUMERLib</c> COM surface cannot carry a full/secured ack —
/// <c>AlarmAckByGUID</c> is <c>E_NOTIMPL</c>, the 8-arg <c>AlarmAckByName</c>
/// is a vendor stub (-55), and neither exposes the signed-ack password. The
/// native request record built by <see cref="WnalReqSetText"/> has six text
/// fields — node, operator, comment, <b>password</b>, domain, full name — so
/// this path can do a fully-attributed (and signed) ack.
///
/// THREADING: these calls are not inherently STA-bound the way the COM
/// consumer is, but keep all of them on the worker STA for ordering/parity
/// with the rest of the alarm surface (marshal via <c>StaRuntime.InvokeAsync</c>).
///
/// x86 ONLY: <c>alarmlst.dll</c> is 32-bit; this compiles into the x86 worker.
///
/// STATUS: the ack-SUBMIT contract (<see cref="WnalReqSetText"/>,
/// <see cref="WnalUserRequest"/>, <see cref="WnalUserRequestEx"/>) and the
/// request-record layout are confirmed by disassembly (see
/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The
/// list-ACQUISITION side (obtaining <c>hAlarmList</c> + alarm handles) is
/// deliberately NOT implemented — see the note at the end of this class.
/// Nothing in the worker calls this type yet; it is kept for when a real
/// list handle can be supplied by a component that owns acquisition.
/// </summary>
public sealed class WnalNativeAckConsumer
{
private const string AlarmLst = "alarmlst.dll";
/// <summary>Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else.</summary>
private const ushort AlmAckVersion = 0x104;
/// <summary>Byte offset where WNALalarmReqSetText begins the string-offset table + heap.</summary>
private const int StringHeapBase = 0x42;
/// <summary>Sentinel the exports reject as an invalid list handle.</summary>
public const int InvalidAlarmList = -1;
// ---------------------------------------------------------------------
// CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI.
// ---------------------------------------------------------------------
/// <summary>
/// Pack operator identity + comment into an existing request record.
/// Arg order is confirmed from the debug format string. Per-field rule:
/// pass <c>null</c> to KEEP the record's existing value, "" to CLEAR it,
/// a string to SET it. Strings are single-byte ASCII (strcpy_s).
/// Return: positive = bytes used; NEGATIVE = record too small, magnitude
/// is the required <c>recSize</c>; 0 = <paramref name="lpAlReq"/> was null.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText",
CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)]
private static extern int WnalReqSetText(
IntPtr lpAlReq,
[MarshalAs(UnmanagedType.LPStr)] string? nodeName,
[MarshalAs(UnmanagedType.LPStr)] string? oper,
[MarshalAs(UnmanagedType.LPStr)] string? comment,
[MarshalAs(UnmanagedType.LPStr)] string? passWd,
[MarshalAs(UnmanagedType.LPStr)] string? domainName,
[MarshalAs(UnmanagedType.LPStr)] string? fullName);
/// <summary>
/// Submit a user request (an ack) over the alarms SELECTED in
/// <paramref name="hAlarmList"/>. a2 is reserved (0); a3 is a capacity
/// hint (the native client passes the grid row count); a4 is scope
/// (0 = all, 1 = selected). Returns 0 on success (provider status),
/// non-zero on error.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALUserRequest",
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck);
/// <summary>
/// Submit a user request (an ack) against an EXPLICIT array of
/// <paramref name="count"/> 4-byte alarm handles — no selection state
/// required. <paramref name="a4"/> mirrors the scope/flag scalar.
/// Returns 0 on success.
/// </summary>
[DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx",
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
private static extern int WnalUserRequestEx(
int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck);
/// <summary>Acknowledge-scope values for the a4 argument.</summary>
private enum AckScope
{
All = 0,
Selected = 1,
}
/// <summary>
/// Build a request record and submit an ack for the given alarm
/// handles via <see cref="WnalUserRequestEx"/>. Caller supplies a live
/// <paramref name="hAlarmList"/> and the handle(s) obtained from the
/// list's records (acquisition is not implemented — see the class note).
/// Returns the native status (0 == success).
/// </summary>
/// <param name="hAlarmList">A registered, subscribed alarm-list handle.</param>
/// <param name="alarmHandles">The 4-byte alarm handles to acknowledge.</param>
/// <param name="comment">Ack comment.</param>
/// <param name="operatorName">Operator / user name.</param>
/// <param name="operatorNode">Operator node name.</param>
/// <param name="operatorDomain">Operator domain (carried natively, unlike the COM path).</param>
/// <param name="operatorFullName">Operator full name (carried natively, unlike the COM path).</param>
/// <param name="password">Operator password for a signed/secured ack, or null.</param>
public int AcknowledgeByHandles(
int hAlarmList,
int[] alarmHandles,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName,
string? password)
{
if (hAlarmList == InvalidAlarmList)
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
if (alarmHandles is null || alarmHandles.Length == 0)
throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles));
IntPtr record = IntPtr.Zero;
IntPtr handles = IntPtr.Zero;
try
{
record = BuildAckRecord(
nodeName: operatorNode,
oper: operatorName,
comment: comment,
passWd: password,
domainName: operatorDomain,
fullName: operatorFullName);
handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int));
for (int i = 0; i < alarmHandles.Length; i++)
Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]);
return WnalUserRequestEx(
hAlarmList,
handles,
alarmHandles.Length,
(uint)AckScope.Selected,
record);
}
finally
{
if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles);
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
}
}
/// <summary>
/// Acknowledge whatever is currently SELECTED in the list (a4 = Selected)
/// via <see cref="WnalUserRequest"/>. Use when selection has already been
/// established on the list rather than passing explicit handles.
/// </summary>
public int AcknowledgeSelected(
int hAlarmList,
uint rowCountHint,
string comment,
string operatorName,
string operatorNode,
string operatorDomain,
string operatorFullName,
string? password)
{
if (hAlarmList == InvalidAlarmList)
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
IntPtr record = IntPtr.Zero;
try
{
record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName);
return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record);
}
finally
{
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
}
}
/// <summary>
/// Allocate a zeroed request record (header + room for the string heap),
/// stamp recSize + version, then let <see cref="WnalReqSetText"/> pack the
/// operator/comment text. Returns an unmanaged buffer the caller must free.
/// </summary>
private static IntPtr BuildAckRecord(
string nodeName, string oper, string comment, string? passWd, string domainName, string fullName)
{
// recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously;
// SetText returns a negative "needed size" if this is too small, which we
// honor by growing once.
int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName);
for (int attempt = 0; attempt < 2; attempt++)
{
IntPtr buf = Marshal.AllocHGlobal(capacity);
try
{
// Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest
// does not validate it; it is informational/provider-filled).
for (int off = 0; off < capacity; off += sizeof(long))
{
int remaining = capacity - off;
if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0);
else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0);
}
Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize
Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104
int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName);
if (rc >= 0)
return buf;
// rc < 0 => record too small; |rc| is the required size. Grow and retry.
capacity = -rc;
Marshal.FreeHGlobal(buf);
}
catch
{
Marshal.FreeHGlobal(buf);
throw;
}
}
throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record.");
}
private static int EstimateCapacity(params string?[] fields)
{
int bytes = StringHeapBase;
foreach (string? f in fields)
bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1;
// Round up with headroom; records are tiny, over-allocation is cheap.
return Math.Max(1024, bytes + 64);
}
// ---------------------------------------------------------------------
// LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion.
//
// The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func
// 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx
// does not define the alarm-record layout — it hands alarmmgr/the provider a
// set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte
// buffer, scalars) that the PROVIDER fills. So the record's GUID offset and
// the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined
// and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg
// meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the
// acquisition from static RE alone would be guessing which bytes are the
// handle/GUID — a wrong guess acks the wrong alarm.
//
// Therefore DO NOT complete the native acquisition by inference. Obtain the
// hAlarmList + handles from a component that already encapsulates
// register/query/match correctly:
// * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID
// (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the
// Alarm Control's own code; managed signatures are exact (no ABI risk).
// It is internal, so invoke by reflection from the x86 worker.
// * Or drive ack through the existing WnWrapAlarmConsumer, which already
// owns a registered/subscribed consumer, and fix the auth gap (Stage 0).
// Keep the confirmed SUBMIT methods above for the case where a real
// hAlarmList + handles are supplied by such a component.
// ---------------------------------------------------------------------
}