feat(worker): route alarm acks through the operator-authenticated AckMsg writer
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
This commit is contained in:
@@ -116,7 +116,7 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
|
public void AcknowledgeAlarm_WithHandler_RoutesNativeStatusIntoHresultAndPayload()
|
||||||
{
|
{
|
||||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||||
Guid g = Guid.NewGuid();
|
Guid g = Guid.NewGuid();
|
||||||
|
|
||||||
@@ -143,7 +143,10 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
Assert.NotNull(reply.AcknowledgeAlarm);
|
Assert.NotNull(reply.AcknowledgeAlarm);
|
||||||
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
|
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
|
||||||
Assert.Equal(g, handler.LastAckGuid);
|
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>
|
/// <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);
|
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]
|
[Fact]
|
||||||
public void AcknowledgeAlarm_WithNonzeroNativeStatus_CarriesDiagnostic()
|
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);
|
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||||
|
|
||||||
StaCommand command = new StaCommand(
|
StaCommand command = new StaCommand(
|
||||||
@@ -188,7 +194,9 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
|
|
||||||
MxCommandReply reply = executor.Execute(command);
|
MxCommandReply reply = executor.Execute(command);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||||
Assert.Equal(-123, reply.Hresult);
|
Assert.Equal(-123, reply.Hresult);
|
||||||
|
Assert.Equal(-123, reply.AcknowledgeAlarm.NativeStatus);
|
||||||
Assert.Contains("-123", reply.DiagnosticMessage);
|
Assert.Contains("-123", reply.DiagnosticMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
|
public void AcknowledgeAlarmByName_WithHandler_RoutesTupleToHandler()
|
||||||
{
|
{
|
||||||
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
|
FakeAlarmHandler handler = new FakeAlarmHandler();
|
||||||
MxAccessCommandExecutor executor = NewExecutor(handler);
|
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||||
|
|
||||||
StaCommand command = new StaCommand(
|
StaCommand command = new StaCommand(
|
||||||
@@ -223,7 +231,133 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
|
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
|
||||||
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
|
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
|
||||||
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
|
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>
|
/// <summary>Verifies that empty alarm name returns invalid request.</summary>
|
||||||
@@ -393,7 +527,10 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AcknowledgeAlarm_WhenHandlerThrows_ReturnsMxaccessFailure()
|
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);
|
MxAccessCommandExecutor executor = NewExecutor(handler);
|
||||||
|
|
||||||
StaCommand command = new StaCommand(
|
StaCommand command = new StaCommand(
|
||||||
@@ -410,7 +547,25 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
MxCommandReply reply = executor.Execute(command);
|
MxCommandReply reply = executor.Execute(command);
|
||||||
|
|
||||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
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)
|
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
|
||||||
@@ -442,14 +597,17 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
/// <summary>Gets the last acknowledge alarm GUID.</summary>
|
/// <summary>Gets the last acknowledge alarm GUID.</summary>
|
||||||
public Guid LastAckGuid { get; private set; }
|
public Guid LastAckGuid { get; private set; }
|
||||||
|
|
||||||
/// <summary>Gets the last acknowledge operator name.</summary>
|
/// <summary>Gets the last acknowledge comment.</summary>
|
||||||
public string? LastAckOperatorName { get; private set; }
|
public string? LastComment { get; private set; }
|
||||||
|
|
||||||
/// <summary>Gets or sets the value returned by acknowledge.</summary>
|
/// <summary>Gets the last acknowledge operator.</summary>
|
||||||
public int AcknowledgeReturn { get; set; }
|
public AlarmAckOperator? LastOperator { get; private set; }
|
||||||
|
|
||||||
/// <summary>Gets or sets a value indicating whether acknowledge throws.</summary>
|
/// <summary>Gets or sets the result returned by both acknowledge methods.</summary>
|
||||||
public bool AcknowledgeThrow { get; set; }
|
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>
|
/// <summary>Gets or sets the query result snapshots.</summary>
|
||||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
|
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
|
||||||
@@ -484,28 +642,32 @@ public sealed class AlarmCommandExecutorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int Acknowledge(
|
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||||
Guid alarmGuid, string comment, string operatorUser,
|
|
||||||
string operatorNode, string operatorDomain, string operatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckGuid = alarmGuid;
|
LastAckGuid = alarmGuid;
|
||||||
LastAckOperatorName = operatorUser;
|
LastComment = comment;
|
||||||
if (AcknowledgeThrow)
|
LastOperator = ackOperator;
|
||||||
|
if (AcknowledgeException is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("simulated alarm-handler failure");
|
throw AcknowledgeException;
|
||||||
}
|
}
|
||||||
return AcknowledgeReturn;
|
|
||||||
|
return AckResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByName(
|
public AlarmAckResult AcknowledgeByName(
|
||||||
string alarmName, string providerName, string groupName,
|
string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator)
|
||||||
string comment, string operatorUser, string operatorNode,
|
|
||||||
string operatorDomain, string operatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
LastAckByNameTuple = (alarmName, providerName, groupName);
|
||||||
LastAckOperatorName = operatorUser;
|
LastComment = comment;
|
||||||
return AcknowledgeReturn;
|
LastOperator = ackOperator;
|
||||||
|
if (AcknowledgeException is not null)
|
||||||
|
{
|
||||||
|
throw AcknowledgeException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AckResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the last acknowledge by name tuple.</summary>
|
/// <summary>Gets the last acknowledge by name tuple.</summary>
|
||||||
|
|||||||
@@ -96,22 +96,100 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
Assert.False(handler.IsSubscribed);
|
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]
|
[Fact]
|
||||||
public void Acknowledge_WhenSubscribed_ForwardsToConsumerWithFullOperatorIdentity()
|
public void Acknowledge_WhenSubscribed_ResolvesTargetAndWritesAsOperator()
|
||||||
{
|
{
|
||||||
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
|
FakeConsumer consumer = new FakeConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
|
||||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
RecordingAckWriter writer = new RecordingAckWriter();
|
||||||
new MxAccessEventQueue(),
|
AlarmCommandHandler handler = NewHandlerWithWriter(() => consumer, writer);
|
||||||
() => consumer);
|
|
||||||
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||||
|
|
||||||
Guid g = Guid.NewGuid();
|
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(AlarmAckOutcome.Applied, result.Outcome);
|
||||||
Assert.Equal(g, consumer.LastAckGuid);
|
Assert.Equal(g, consumer.LastResolvedGuid);
|
||||||
Assert.Equal("u", consumer.LastAckOperatorName);
|
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>
|
/// <summary>Verifies that acknowledge throws invalid operation when called before subscribe.</summary>
|
||||||
@@ -123,7 +201,7 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
() => new FakeConsumer());
|
() => new FakeConsumer());
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(
|
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>
|
/// <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(
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||||
new MxAccessEventQueue(),
|
new MxAccessEventQueue(),
|
||||||
() => consumer,
|
() => consumer,
|
||||||
() => guardInvocations++);
|
() => guardInvocations++,
|
||||||
|
comFactory: null,
|
||||||
|
standbyFactory: null,
|
||||||
|
ackWriterFactory: () => new RecordingAckWriter());
|
||||||
|
|
||||||
// Subscribe is the first call — guard must run before the consumer
|
// Subscribe is the first call — guard must run before the consumer
|
||||||
// factory is invoked. We tally invocation counts after each call so
|
// 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");
|
handler.Subscribe(new SubscribeAlarmsCommand { SubscriptionExpression = @"\\HOST\Galaxy!A" }, "s1");
|
||||||
Assert.Equal(1, guardInvocations);
|
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);
|
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);
|
Assert.Equal(3, guardInvocations);
|
||||||
|
|
||||||
_ = handler.QueryActive(null, out _);
|
_ = handler.QueryActive(null, out _);
|
||||||
@@ -323,9 +404,9 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
// assert each independently with a fresh handler. Below we reuse
|
// assert each independently with a fresh handler. Below we reuse
|
||||||
// the same throwing handler for the not-subscribed-yet entries:
|
// the same throwing handler for the not-subscribed-yet entries:
|
||||||
Assert.Throws<InvalidOperationException>(
|
Assert.Throws<InvalidOperationException>(
|
||||||
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
() => handler.Acknowledge(Guid.Empty, "", new AlarmAckOperator()));
|
||||||
Assert.Throws<InvalidOperationException>(
|
Assert.Throws<InvalidOperationException>(
|
||||||
() => handler.AcknowledgeByName("", "", "", "", "", "", "", ""));
|
() => handler.AcknowledgeByName("", "", "", "", new AlarmAckOperator()));
|
||||||
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
Assert.Throws<InvalidOperationException>(() => handler.QueryActive(null, out _));
|
||||||
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
Assert.Throws<InvalidOperationException>(() => handler.PollOnce());
|
||||||
Assert.Throws<InvalidOperationException>(
|
Assert.Throws<InvalidOperationException>(
|
||||||
@@ -422,16 +503,17 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
|
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
|
||||||
{
|
{
|
||||||
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||||
FakeConsumer initialStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
FakeConsumer initialStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||||
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||||
int buildCount = 0;
|
int buildCount = 0;
|
||||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||||
new MxAccessEventQueue(),
|
new MxAccessEventQueue(),
|
||||||
() => primary,
|
() => primary,
|
||||||
threadAffinityCheck: null,
|
threadAffinityCheck: null,
|
||||||
comFactory: null,
|
comFactory: null,
|
||||||
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement);
|
standbyFactory: _ => ++buildCount == 1 ? initialStandby : replacement,
|
||||||
|
ackWriterFactory: () => new RecordingAckWriter());
|
||||||
|
|
||||||
handler.Subscribe(new SubscribeAlarmsCommand
|
handler.Subscribe(new SubscribeAlarmsCommand
|
||||||
{
|
{
|
||||||
@@ -443,7 +525,7 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
Assert.Equal(2, buildCount);
|
Assert.Equal(2, buildCount);
|
||||||
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
|
Assert.Equal(@"\\HOST\Galaxy!Area", replacement.LastSubscription);
|
||||||
Assert.True(initialStandby.Disposed);
|
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>
|
/// <summary>A replacement is rejected when no fallback-capable subscription is active.</summary>
|
||||||
@@ -471,16 +553,17 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
|
public void UpdateWatchList_WhenReplacementSubscribeFails_PreservesOldStandbyAndPrimaryMode()
|
||||||
{
|
{
|
||||||
FakeConsumer primary = new FakeConsumer { AcknowledgeReturn = 11 };
|
FakeConsumer primary = new FakeConsumer { AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||||
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||||
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 33 };
|
FakeConsumer failedReplacement = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||||
int buildCount = 0;
|
int buildCount = 0;
|
||||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||||
new MxAccessEventQueue(),
|
new MxAccessEventQueue(),
|
||||||
() => primary,
|
() => primary,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
_ => ++buildCount == 1 ? oldStandby : failedReplacement);
|
_ => ++buildCount == 1 ? oldStandby : failedReplacement,
|
||||||
|
() => new RecordingAckWriter());
|
||||||
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
||||||
{
|
{
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||||
@@ -494,23 +577,24 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
|
|
||||||
Assert.False(oldStandby.Disposed);
|
Assert.False(oldStandby.Disposed);
|
||||||
Assert.True(failedReplacement.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>
|
/// <summary>A successful replacement while failed over keeps subtag mode active and delegates to the new standby.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
|
public void UpdateWatchList_WhileSubtagActive_PreservesModeAndUsesReplacement()
|
||||||
{
|
{
|
||||||
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AcknowledgeReturn = 11 };
|
FakeConsumer primary = new FakeConsumer { ThrowOnSubscribe = true, AckTarget = AlarmAckTarget.For("11.AckMsg") };
|
||||||
FakeConsumer oldStandby = new FakeConsumer { AcknowledgeReturn = 22 };
|
FakeConsumer oldStandby = new FakeConsumer { AckTarget = AlarmAckTarget.For("22.AckMsg") };
|
||||||
FakeConsumer replacement = new FakeConsumer { AcknowledgeReturn = 33 };
|
FakeConsumer replacement = new FakeConsumer { AckTarget = AlarmAckTarget.For("33.AckMsg") };
|
||||||
int buildCount = 0;
|
int buildCount = 0;
|
||||||
AlarmCommandHandler handler = new AlarmCommandHandler(
|
AlarmCommandHandler handler = new AlarmCommandHandler(
|
||||||
new MxAccessEventQueue(),
|
new MxAccessEventQueue(),
|
||||||
() => primary,
|
() => primary,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
_ => ++buildCount == 1 ? oldStandby : replacement);
|
_ => ++buildCount == 1 ? oldStandby : replacement,
|
||||||
|
() => new RecordingAckWriter());
|
||||||
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
SubscribeAlarmsCommand subscribe = new SubscribeAlarmsCommand
|
||||||
{
|
{
|
||||||
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
SubscriptionExpression = @"\\HOST\Galaxy!Area",
|
||||||
@@ -523,7 +607,7 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
|
handler.UpdateWatchList(new[] { NewTarget("Galaxy!Area.Tank02.Alarm") });
|
||||||
|
|
||||||
Assert.True(oldStandby.Disposed);
|
Assert.True(oldStandby.Disposed);
|
||||||
Assert.Equal(33, handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
|
Assert.Equal("33.AckMsg", AckItemOf(handler));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -567,6 +651,19 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
Assert.Equal(at, evt.OnAlarmProviderModeChanged.At.ToDateTime());
|
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)
|
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
|
||||||
{
|
{
|
||||||
return new MxAlarmSnapshotRecord
|
return new MxAlarmSnapshotRecord
|
||||||
@@ -598,12 +695,12 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
|
|
||||||
/// <summary>Gets the last subscription request.</summary>
|
/// <summary>Gets the last subscription request.</summary>
|
||||||
public string? LastSubscription { get; private set; }
|
public string? LastSubscription { get; private set; }
|
||||||
/// <summary>Gets the last acknowledged alarm GUID.</summary>
|
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||||
public Guid LastAckGuid { get; private set; }
|
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||||
/// <summary>Gets the last acknowledged operator name.</summary>
|
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||||
public string? LastAckOperatorName { get; private set; }
|
public Guid LastResolvedGuid { get; private set; }
|
||||||
/// <summary>Gets or sets the return value for acknowledge operations.</summary>
|
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||||
public int AcknowledgeReturn { get; set; }
|
public (string Name, string Provider, string Group)? LastResolvedName { get; private set; }
|
||||||
/// <summary>Gets or sets the snapshot result to return.</summary>
|
/// <summary>Gets or sets the snapshot result to return.</summary>
|
||||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||||
Array.Empty<MxAlarmSnapshotRecord>();
|
Array.Empty<MxAlarmSnapshotRecord>();
|
||||||
@@ -623,29 +720,19 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByGuid(
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||||
Guid alarmGuid, string ackComment, string ackOperatorName,
|
|
||||||
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckGuid = alarmGuid;
|
LastResolvedGuid = alarmGuid;
|
||||||
LastAckOperatorName = ackOperatorName;
|
return AckTarget;
|
||||||
return AcknowledgeReturn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByName(
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
||||||
string alarmName, string providerName, string groupName,
|
|
||||||
string ackComment, string ackOperatorName, string ackOperatorNode,
|
|
||||||
string ackOperatorDomain, string ackOperatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
LastResolvedName = (alarmName, providerName, groupName);
|
||||||
LastAckOperatorName = ackOperatorName;
|
return AckTarget;
|
||||||
return AcknowledgeReturn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||||
public bool SnapshotTruncated { get; set; }
|
public bool SnapshotTruncated { get; set; }
|
||||||
|
|
||||||
@@ -671,4 +758,22 @@ public sealed class AlarmCommandHandlerTests
|
|||||||
Disposed = true;
|
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();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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
|
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ public sealed class AlarmDispatcherTests
|
|||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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);
|
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
|
||||||
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||||
@@ -142,7 +142,7 @@ public sealed class AlarmDispatcherTests
|
|||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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
|
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||||
{
|
{
|
||||||
@@ -177,7 +177,7 @@ public sealed class AlarmDispatcherTests
|
|||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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
|
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
||||||
{
|
{
|
||||||
@@ -205,155 +205,140 @@ public sealed class AlarmDispatcherTests
|
|||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
new FakeAckWriter());
|
||||||
|
|
||||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||||
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
|
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]
|
[Fact]
|
||||||
public void Acknowledge_WhenInvoked_ForwardsToConsumerWithFullOperatorIdentity()
|
public void Acknowledge_ResolvesTargetThenWritesWithOperator()
|
||||||
{
|
{
|
||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
|
||||||
consumer.AcknowledgeReturn = 0;
|
FakeAckWriter writer = new FakeAckWriter();
|
||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
|
||||||
SessionId);
|
|
||||||
|
|
||||||
Guid guid = Guid.NewGuid();
|
Guid guid = Guid.NewGuid();
|
||||||
int rc = dispatcher.Acknowledge(
|
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
|
||||||
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
|
||||||
|
|
||||||
Assert.Equal(0, rc);
|
AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
|
||||||
Assert.Equal(guid, consumer.LastAckGuid);
|
|
||||||
Assert.Equal("Acked", consumer.LastAckComment);
|
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||||
Assert.Equal("alice", consumer.LastAckOperatorName);
|
Assert.Equal(guid, consumer.LastResolvedGuid);
|
||||||
Assert.Equal("WS01", consumer.LastAckOperatorNode);
|
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
|
||||||
Assert.Equal("CORP", consumer.LastAckOperatorDomain);
|
Assert.Equal("checked", writer.LastComment);
|
||||||
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
|
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]
|
[Fact]
|
||||||
public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
|
public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
|
||||||
{
|
{
|
||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
|
||||||
FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
|
FakeAckWriter writer = new FakeAckWriter();
|
||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
|
||||||
SessionId,
|
|
||||||
managed);
|
|
||||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
|
||||||
|
|
||||||
Guid guid = Guid.NewGuid();
|
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
|
||||||
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
|
||||||
|
|
||||||
Assert.Equal(0, rc);
|
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
|
||||||
Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
|
Assert.Equal("gone", result.Diagnostic);
|
||||||
Assert.Equal(guid, managed.LastAckGuid);
|
Assert.Null(writer.LastItemAddress);
|
||||||
Assert.Equal("CORP", managed.LastAckOperatorDomain);
|
|
||||||
Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
|
|
||||||
Assert.Equal(Guid.Empty, consumer.LastAckGuid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Verifies acknowledge-by-name resolves with the full tuple and writes the resolved item.</summary>
|
||||||
/// Verifies that a failed managed-client lifecycle neither fails the alarm
|
|
||||||
/// subscription nor leaves acks routed to the broken client.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
|
public void AcknowledgeByName_ResolvesTupleThenWritesWithOperator()
|
||||||
{
|
{
|
||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer
|
||||||
FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
|
{
|
||||||
|
AckTarget = AlarmAckTarget.For("TestMachine_001.TestAlarm001.AckMsg"),
|
||||||
|
};
|
||||||
|
FakeAckWriter writer = new FakeAckWriter();
|
||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Domain = "CORP" };
|
||||||
SessionId,
|
|
||||||
managed);
|
|
||||||
|
|
||||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
AlarmAckResult result = dispatcher.AcknowledgeByName(
|
||||||
Guid guid = Guid.NewGuid();
|
"TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "ack", op);
|
||||||
int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
|
|
||||||
|
|
||||||
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
|
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
||||||
Assert.True(managed.Disposed);
|
Assert.Equal(("TestMachine_001.TestAlarm001", "Galaxy", "TestArea"), consumer.LastResolvedName);
|
||||||
Assert.Equal(0, rc);
|
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", writer.LastItemAddress);
|
||||||
Assert.Equal(guid, consumer.LastAckGuid);
|
Assert.Equal("ack", writer.LastComment);
|
||||||
Assert.Equal(Guid.Empty, managed.LastAckGuid);
|
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>
|
/// <summary>
|
||||||
/// Verifies that while the failover consumer serves the subtag standby,
|
/// 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>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
|
public void Acknowledge_WhenFailoverInSubtagMode_ResolvesThroughStandby()
|
||||||
{
|
{
|
||||||
FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
|
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(
|
FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
|
||||||
primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
|
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(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
failover,
|
failover,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId,
|
SessionId,
|
||||||
managed);
|
writer);
|
||||||
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
|
||||||
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
|
Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
|
||||||
|
|
||||||
Guid guid = Guid.NewGuid();
|
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.LastResolvedGuid);
|
||||||
Assert.Equal(guid, standby.LastAckGuid);
|
Assert.Equal(Guid.Empty, primary.LastResolvedGuid);
|
||||||
Assert.Equal(Guid.Empty, managed.LastAckGuid);
|
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]
|
[Fact]
|
||||||
public void Dispose_WithManagedAckClient_DisposesManagedClient()
|
public void Dispose_DoesNotDisposeAckWriter()
|
||||||
{
|
{
|
||||||
FakeManagedAckClient managed = new FakeManagedAckClient();
|
FakeAckWriter writer = new FakeAckWriter();
|
||||||
AlarmDispatcher dispatcher = new AlarmDispatcher(
|
AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
new FakeAlarmConsumer(),
|
new FakeAlarmConsumer(),
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId,
|
SessionId,
|
||||||
managed);
|
writer);
|
||||||
|
|
||||||
dispatcher.Dispose();
|
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]
|
[Fact]
|
||||||
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
|
public void Constructor_NullAckWriter_Throws()
|
||||||
{
|
{
|
||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
|
Assert.Throws<ArgumentNullException>(() => new AlarmDispatcher(
|
||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
new FakeAlarmConsumer(),
|
||||||
consumer,
|
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
null!));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that consumer alarm records are mapped to proto snapshots.</summary>
|
/// <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(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
new FakeAckWriter());
|
||||||
|
|
||||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||||
Assert.Equal(2, snapshots.Count);
|
Assert.Equal(2, snapshots.Count);
|
||||||
@@ -447,7 +433,8 @@ public sealed class AlarmDispatcherTests
|
|||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
new FakeAckWriter());
|
||||||
|
|
||||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out _);
|
||||||
Assert.Equal(2, snapshots.Count);
|
Assert.Equal(2, snapshots.Count);
|
||||||
@@ -482,7 +469,8 @@ public sealed class AlarmDispatcherTests
|
|||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
new FakeAckWriter());
|
||||||
|
|
||||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||||
|
|
||||||
@@ -512,7 +500,8 @@ public sealed class AlarmDispatcherTests
|
|||||||
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
||||||
consumer,
|
consumer,
|
||||||
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
|
||||||
SessionId);
|
SessionId,
|
||||||
|
new FakeAckWriter());
|
||||||
|
|
||||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms(out bool truncated);
|
||||||
|
|
||||||
@@ -542,7 +531,7 @@ public sealed class AlarmDispatcherTests
|
|||||||
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
||||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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();
|
dispatcher.Dispose();
|
||||||
|
|
||||||
@@ -569,20 +558,12 @@ public sealed class AlarmDispatcherTests
|
|||||||
|
|
||||||
/// <summary>Gets the last subscription reference.</summary>
|
/// <summary>Gets the last subscription reference.</summary>
|
||||||
public string? LastSubscription { get; private set; }
|
public string? LastSubscription { get; private set; }
|
||||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
/// <summary>Gets or sets the ack target both resolve methods return.</summary>
|
||||||
public Guid LastAckGuid { get; private set; }
|
public AlarmAckTarget AckTarget { get; set; } = AlarmAckTarget.For("Fake.Alarm.AckMsg");
|
||||||
/// <summary>Gets the comment from the last acknowledge call.</summary>
|
/// <summary>Gets the GUID from the last ResolveAckTarget call.</summary>
|
||||||
public string? LastAckComment { get; private set; }
|
public Guid LastResolvedGuid { get; private set; }
|
||||||
/// <summary>Gets the operator name from the last acknowledge call.</summary>
|
/// <summary>Gets the (name, provider, group) from the last ResolveAckTargetByName call.</summary>
|
||||||
public string? LastAckOperatorName { get; private set; }
|
public (string Name, string Provider, string Group)? LastResolvedName { 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 result collection for snapshot operations.</summary>
|
/// <summary>Gets or sets the result collection for snapshot operations.</summary>
|
||||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
|
||||||
Array.Empty<MxAlarmSnapshotRecord>();
|
Array.Empty<MxAlarmSnapshotRecord>();
|
||||||
@@ -607,37 +588,19 @@ public sealed class AlarmDispatcherTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByGuid(
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
||||||
Guid alarmGuid,
|
|
||||||
string ackComment,
|
|
||||||
string ackOperatorName,
|
|
||||||
string ackOperatorNode,
|
|
||||||
string ackOperatorDomain,
|
|
||||||
string ackOperatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckGuid = alarmGuid;
|
LastResolvedGuid = alarmGuid;
|
||||||
LastAckComment = ackComment;
|
return AckTarget;
|
||||||
LastAckOperatorName = ackOperatorName;
|
|
||||||
LastAckOperatorNode = ackOperatorNode;
|
|
||||||
LastAckOperatorDomain = ackOperatorDomain;
|
|
||||||
LastAckOperatorFullName = ackOperatorFullName;
|
|
||||||
return AcknowledgeReturn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByName(
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
||||||
string alarmName, string providerName, string groupName,
|
|
||||||
string ackComment, string ackOperatorName, string ackOperatorNode,
|
|
||||||
string ackOperatorDomain, string ackOperatorFullName)
|
|
||||||
{
|
{
|
||||||
LastAckByNameTuple = (alarmName, providerName, groupName);
|
LastResolvedName = (alarmName, providerName, groupName);
|
||||||
LastAckOperatorName = ackOperatorName;
|
return AckTarget;
|
||||||
return AcknowledgeReturn;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
/// <summary>Gets or sets the truncation verdict this consumer reports with its snapshot.</summary>
|
||||||
public bool SnapshotTruncated { get; set; }
|
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? LastItemAddress { get; private set; }
|
||||||
public string? LastSubscription { get; private set; }
|
public string? LastComment { get; private set; }
|
||||||
/// <summary>Gets the GUID from the last acknowledge call.</summary>
|
public AlarmAckOperator? LastOperator { get; private set; }
|
||||||
public Guid LastAckGuid { get; private set; }
|
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||||
/// <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 bool Disposed { get; private set; }
|
public bool Disposed { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
|
||||||
public void Subscribe(string subscription)
|
|
||||||
{
|
{
|
||||||
if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
|
LastItemAddress = itemAddress;
|
||||||
LastSubscription = subscription;
|
LastComment = comment;
|
||||||
|
LastOperator = ackOperator;
|
||||||
|
return Result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
public void Dispose() => Disposed = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,15 +663,13 @@ public sealed class MxAccessStaSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int Acknowledge(Guid alarmGuid, string comment, string operatorUser,
|
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||||
string operatorNode, string operatorDomain, string operatorFullName)
|
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||||
=> 0;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByName(string alarmName, string providerName, string groupName,
|
public AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName,
|
||||||
string comment, string operatorUser, string operatorNode,
|
string comment, AlarmAckOperator ackOperator)
|
||||||
string operatorDomain, string operatorFullName)
|
=> AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
||||||
=> 0;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(
|
||||||
|
|||||||
@@ -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>\\<machine>\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}");
|
|
||||||
}
|
|
||||||
@@ -86,7 +86,12 @@ public sealed class AlarmsLiveSmokeTests
|
|||||||
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
|
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
|
||||||
maxAlarmsPerFetch: 1024);
|
maxAlarmsPerFetch: 1024);
|
||||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
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.");
|
Log("Constructed consumer + sink + dispatcher.");
|
||||||
dispatcher.Subscribe(SubscriptionExpression);
|
dispatcher.Subscribe(SubscriptionExpression);
|
||||||
@@ -139,26 +144,23 @@ public sealed class AlarmsLiveSmokeTests
|
|||||||
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
|
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
|
||||||
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
|
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
|
||||||
|
|
||||||
// Try the ack with real Windows identity. AVEVA's AlarmAckByName
|
// The ack authenticates a Galaxy operator and writes <tag>.AckMsg.
|
||||||
// may reject synthetic operator strings; using the current process
|
// Operator credentials come from the environment (never logged);
|
||||||
// identity gives the alarm-history a recognizable principal.
|
// the user defaults to the current process identity.
|
||||||
string realUser = Environment.UserName;
|
AlarmAckOperator ackOperator = new AlarmAckOperator
|
||||||
string realNode = Environment.MachineName;
|
{
|
||||||
string realDomain = Environment.UserDomainName ?? string.Empty;
|
UserName = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_USER") ?? Environment.UserName,
|
||||||
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
|
Password = Environment.GetEnvironmentVariable("MXGATEWAY_PROBE_ACK_PASSWORD") ?? string.Empty,
|
||||||
|
};
|
||||||
|
Log($"Ack identity: {ackOperator}");
|
||||||
|
|
||||||
int rc = dispatcher.AcknowledgeByName(
|
AlarmAckResult result = dispatcher.AcknowledgeByName(
|
||||||
alarmName: alarmName,
|
alarmName, provider, group, "alarms-live-smoke ack", ackOperator);
|
||||||
providerName: provider,
|
Log($"AcknowledgeByName -> outcome={result.Outcome} native={result.NativeStatus} diag='{result.Diagnostic}'");
|
||||||
groupName: group,
|
|
||||||
ackComment: "alarms-live-smoke ack",
|
|
||||||
ackOperatorName: realUser,
|
|
||||||
ackOperatorNode: realNode,
|
|
||||||
ackOperatorDomain: realDomain,
|
|
||||||
ackOperatorFullName: realUser);
|
|
||||||
Log($"AcknowledgeByName(real identity) -> rc={rc}");
|
|
||||||
|
|
||||||
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
|
// 4. Wait for the post-ack transition. With the alarm flipping every
|
||||||
// 10s and the consumer polling every 500ms, the next state
|
// 10s and the consumer polling every 500ms, the next state
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
|
|||||||
options,
|
options,
|
||||||
() => Process.GetCurrentProcess().Id,
|
() => Process.GetCurrentProcess().Id,
|
||||||
new WorkerPipeSessionOptions(),
|
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,
|
logger,
|
||||||
stream)
|
stream)
|
||||||
{
|
{
|
||||||
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
|
|||||||
options,
|
options,
|
||||||
processIdProvider,
|
processIdProvider,
|
||||||
new WorkerPipeSessionOptions(),
|
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)
|
logger: null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
|
|||||||
// parameterless CompleteStartupHandshakeAsync is used without a
|
// parameterless CompleteStartupHandshakeAsync is used without a
|
||||||
// prior factory call.
|
// prior factory call.
|
||||||
_runtimeSession ??= new MxAccessStaSession(
|
_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;
|
IWorkerRuntimeSession session = _runtimeSession;
|
||||||
try
|
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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
private readonly Action? threadAffinityCheck;
|
private readonly Action? threadAffinityCheck;
|
||||||
private readonly IMxAccessComObjectFactory? comFactory;
|
private readonly IMxAccessComObjectFactory? comFactory;
|
||||||
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
|
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 MxAccessEventMapper mapper = new MxAccessEventMapper();
|
||||||
private readonly object syncRoot = new object();
|
private readonly object syncRoot = new object();
|
||||||
|
private readonly IAlarmAckWriter deferredAckWriter;
|
||||||
private AlarmDispatcher? dispatcher;
|
private AlarmDispatcher? dispatcher;
|
||||||
|
private IAlarmAckWriter? ackWriter;
|
||||||
private FailoverAlarmConsumer? failoverConsumer;
|
private FailoverAlarmConsumer? failoverConsumer;
|
||||||
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
|
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
|
||||||
private string subscribeSessionId = string.Empty;
|
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
|
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
|
||||||
/// live COM factory.
|
/// live COM factory.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="managedAckFactory">
|
/// <param name="ackWriterFactory">
|
||||||
/// Optional per-subscription factory for a managed ack client
|
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
|
||||||
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
|
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
|
||||||
/// client, GUID-keyed acknowledges route through AVEVA's modern
|
/// <paramref name="comFactory"/> that registers its own MXAccess server
|
||||||
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
|
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
|
||||||
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
|
/// The writer is created once per handler, on the first acknowledgement
|
||||||
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
|
/// (so a subscription never fails because ack is unavailable), survives
|
||||||
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
|
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
|
||||||
|
/// inject a fake.
|
||||||
/// </param>
|
/// </param>
|
||||||
public AlarmCommandHandler(
|
public AlarmCommandHandler(
|
||||||
MxAccessEventQueue eventQueue,
|
MxAccessEventQueue eventQueue,
|
||||||
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
Action? threadAffinityCheck,
|
Action? threadAffinityCheck,
|
||||||
IMxAccessComObjectFactory? comFactory,
|
IMxAccessComObjectFactory? comFactory,
|
||||||
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
||||||
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
|
Func<IAlarmAckWriter>? ackWriterFactory = null)
|
||||||
{
|
{
|
||||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||||
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
||||||
this.threadAffinityCheck = threadAffinityCheck;
|
this.threadAffinityCheck = threadAffinityCheck;
|
||||||
this.comFactory = comFactory;
|
this.comFactory = comFactory;
|
||||||
this.standbyFactory = standbyFactory;
|
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>
|
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
|
||||||
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
failover.ProviderModeChanged += providerModeChangedHandler;
|
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);
|
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
|
||||||
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
|
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
dispatcher.Subscribe(subscriptionExpression);
|
dispatcher.Subscribe(subscriptionExpression);
|
||||||
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int Acknowledge(
|
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||||
Guid alarmGuid,
|
|
||||||
string comment,
|
|
||||||
string operatorUser,
|
|
||||||
string operatorNode,
|
|
||||||
string operatorDomain,
|
|
||||||
string operatorFullName)
|
|
||||||
{
|
{
|
||||||
|
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||||
threadAffinityCheck?.Invoke();
|
threadAffinityCheck?.Invoke();
|
||||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||||
return d.Acknowledge(
|
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
|
||||||
alarmGuid,
|
|
||||||
comment ?? string.Empty,
|
|
||||||
operatorUser ?? string.Empty,
|
|
||||||
operatorNode ?? string.Empty,
|
|
||||||
operatorDomain ?? string.Empty,
|
|
||||||
operatorFullName ?? string.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int AcknowledgeByName(
|
public AlarmAckResult AcknowledgeByName(
|
||||||
string alarmName,
|
string alarmName,
|
||||||
string providerName,
|
string providerName,
|
||||||
string groupName,
|
string groupName,
|
||||||
string comment,
|
string comment,
|
||||||
string operatorUser,
|
AlarmAckOperator ackOperator)
|
||||||
string operatorNode,
|
|
||||||
string operatorDomain,
|
|
||||||
string operatorFullName)
|
|
||||||
{
|
{
|
||||||
|
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||||
threadAffinityCheck?.Invoke();
|
threadAffinityCheck?.Invoke();
|
||||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||||
return d.AcknowledgeByName(
|
return d.AcknowledgeByName(
|
||||||
alarmName ?? string.Empty,
|
alarmName ?? string.Empty,
|
||||||
providerName ?? string.Empty,
|
providerName ?? string.Empty,
|
||||||
groupName ?? string.Empty,
|
groupName ?? string.Empty,
|
||||||
comment ?? string.Empty,
|
comment ?? string.Empty,
|
||||||
operatorUser ?? string.Empty,
|
ackOperator);
|
||||||
operatorNode ?? string.Empty,
|
|
||||||
operatorDomain ?? string.Empty,
|
|
||||||
operatorFullName ?? string.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
d?.PollOnce();
|
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()
|
private AlarmDispatcher GetDispatcherOrThrow()
|
||||||
{
|
{
|
||||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||||
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
Unsubscribe();
|
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> /
|
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
|
||||||
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
|
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
|
||||||
/// through <see cref="AlarmCommandHandler"/>, which owns one
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
|
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
|
||||||
@@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
|
|||||||
private readonly MxAccessAlarmEventSink sink;
|
private readonly MxAccessAlarmEventSink sink;
|
||||||
private readonly string sessionId;
|
private readonly string sessionId;
|
||||||
private readonly EventHandler<MxAlarmTransitionEvent> handler;
|
private readonly EventHandler<MxAlarmTransitionEvent> handler;
|
||||||
private IManagedAlarmAckClient? managedAck;
|
private readonly IAlarmAckWriter ackWriter;
|
||||||
private bool disposed;
|
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="consumer">The alarm consumer.</param>
|
||||||
/// <param name="sink">The alarm event sink.</param>
|
/// <param name="sink">The alarm event sink.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="managedAck">
|
/// <param name="ackWriter">
|
||||||
/// Optional managed ack client. When non-null, GUID-keyed acknowledges
|
/// Writes the resolved ack item as the authenticated operator. Owned by
|
||||||
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) instead
|
/// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
|
||||||
/// of the wnwrap consumer while the alarmmgr provider is active; null
|
/// dispatcher never does, so the writer's registered MXAccess server
|
||||||
/// keeps the existing wnwrap ack path. Subtag-mode acks always stay on
|
/// outlives an unsubscribe/resubscribe cycle.
|
||||||
/// the consumer because their GUIDs are synthetic.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public AlarmDispatcher(
|
public AlarmDispatcher(
|
||||||
IMxAccessAlarmConsumer consumer,
|
IMxAccessAlarmConsumer consumer,
|
||||||
MxAccessAlarmEventSink sink,
|
MxAccessAlarmEventSink sink,
|
||||||
string sessionId,
|
string sessionId,
|
||||||
IManagedAlarmAckClient? managedAck = null)
|
IAlarmAckWriter ackWriter)
|
||||||
{
|
{
|
||||||
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||||
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||||
this.sessionId = sessionId ?? string.Empty;
|
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
|
// Sink.Attach is the seam that propagates the session id onto the
|
||||||
// proto SessionId field of every emitted MxEvent. Pass the consumer
|
// proto SessionId field of every emitted MxEvent. Pass the consumer
|
||||||
// as the "associated COM object" — sink ignores the object reference
|
// 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));
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||||
consumer.Subscribe(subscription);
|
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>
|
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</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>
|
|
||||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||||
/// <param name="ackOperatorName">The operator name.</param>
|
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||||
/// <param name="ackOperatorNode">The operator node.</param>
|
/// <returns>The outcome.</returns>
|
||||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
|
||||||
/// <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)
|
|
||||||
{
|
{
|
||||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||||
if (managedAck is not null && !IsSubtagActive())
|
AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
|
||||||
{
|
return target.Found
|
||||||
// Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries
|
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
||||||
// operator domain + full name and is not E_NOTIMPL like the wnwrap
|
: AlarmAckResult.FromTarget(target);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
|
||||||
/// Acknowledge an alarm by its (name, provider, group) tuple.
|
/// <param name="alarmName">The tag/alarm name.</param>
|
||||||
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
|
/// <param name="providerName">The provider.</param>
|
||||||
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
|
/// <param name="groupName">The group/area.</param>
|
||||||
/// </summary>
|
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||||
/// <param name="alarmName">The alarm name.</param>
|
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||||
/// <param name="providerName">The provider name.</param>
|
/// <returns>The outcome.</returns>
|
||||||
/// <param name="groupName">The group name.</param>
|
public AlarmAckResult AcknowledgeByName(
|
||||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
|
||||||
/// <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)
|
|
||||||
{
|
{
|
||||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||||
return consumer.AcknowledgeByName(
|
AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
|
||||||
alarmName,
|
return target.Found
|
||||||
providerName,
|
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
||||||
groupName,
|
: AlarmAckResult.FromTarget(target);
|
||||||
ackComment,
|
|
||||||
ackOperatorName,
|
|
||||||
ackOperatorNode,
|
|
||||||
ackOperatorDomain,
|
|
||||||
ackOperatorFullName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable
|
|||||||
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
|
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
|
||||||
try { sink.Detach(); } catch { /* swallow */ }
|
try { sink.Detach(); } catch { /* swallow */ }
|
||||||
try { consumer.Dispose(); } catch { /* swallow */ }
|
try { consumer.Dispose(); } catch { /* swallow */ }
|
||||||
try { managedAck?.Dispose(); } catch { /* swallow */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
|
|||||||
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
||||||
void Unsubscribe();
|
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="alarmGuid">The alarm GUID.</param>
|
||||||
/// <param name="comment">The acknowledgment comment.</param>
|
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||||
/// <param name="operatorUser">The operator user name.</param>
|
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||||
/// <param name="operatorNode">The operator node name.</param>
|
/// <returns>The acknowledgement outcome.</returns>
|
||||||
/// <param name="operatorDomain">The operator domain name.</param>
|
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
|
||||||
/// <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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
|
||||||
/// Acknowledge a single alarm by (name, provider, group) — used when
|
/// <param name="alarmName">The alarm (tag) name.</param>
|
||||||
/// the caller has the human-readable reference but not the GUID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="alarmName">The alarm name.</param>
|
|
||||||
/// <param name="providerName">The provider name.</param>
|
/// <param name="providerName">The provider name.</param>
|
||||||
/// <param name="groupName">The group name.</param>
|
/// <param name="groupName">The group name.</param>
|
||||||
/// <param name="comment">The acknowledgment comment.</param>
|
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||||
/// <param name="operatorUser">The operator user name.</param>
|
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||||
/// <param name="operatorNode">The operator node name.</param>
|
/// <returns>The acknowledgement outcome.</returns>
|
||||||
/// <param name="operatorDomain">The operator domain name.</param>
|
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
|
||||||
/// <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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapshot the currently-active alarm set, optionally scoped to a
|
/// 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>\\<node>\Galaxy!<area></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);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
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}'.");
|
$"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
|
try
|
||||||
{
|
{
|
||||||
int rc = alarmCommandHandler.Acknowledge(
|
AlarmAckResult result = alarmCommandHandler.Acknowledge(
|
||||||
alarmGuid,
|
alarmGuid,
|
||||||
payload.Comment,
|
payload.Comment,
|
||||||
payload.OperatorUser,
|
new AlarmAckOperator
|
||||||
payload.OperatorNode,
|
{
|
||||||
payload.OperatorDomain,
|
UserName = payload.OperatorUser,
|
||||||
payload.OperatorFullName);
|
Domain = payload.OperatorDomain,
|
||||||
MxCommandReply reply = CreateOkReply(command);
|
Password = payload.OperatorPassword,
|
||||||
reply.Hresult = rc;
|
});
|
||||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
return CreateAcknowledgeReply(command, result);
|
||||||
{
|
|
||||||
NativeStatus = rc,
|
|
||||||
};
|
|
||||||
if (rc != 0)
|
|
||||||
{
|
|
||||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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.");
|
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
int rc = alarmCommandHandler.AcknowledgeByName(
|
AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
|
||||||
payload.AlarmName,
|
payload.AlarmName,
|
||||||
payload.ProviderName,
|
payload.ProviderName,
|
||||||
payload.GroupName,
|
payload.GroupName,
|
||||||
payload.Comment,
|
payload.Comment,
|
||||||
payload.OperatorUser,
|
new AlarmAckOperator
|
||||||
payload.OperatorNode,
|
{
|
||||||
payload.OperatorDomain,
|
UserName = payload.OperatorUser,
|
||||||
payload.OperatorFullName);
|
Domain = payload.OperatorDomain,
|
||||||
MxCommandReply reply = CreateOkReply(command);
|
Password = payload.OperatorPassword,
|
||||||
reply.Hresult = rc;
|
});
|
||||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
return CreateAcknowledgeReply(command, result);
|
||||||
{
|
|
||||||
NativeStatus = rc,
|
|
||||||
};
|
|
||||||
if (rc != 0)
|
|
||||||
{
|
|
||||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
|
|
||||||
}
|
|
||||||
return reply;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
|
||||||
{
|
{
|
||||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
|
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user