Compare commits

...

7 Commits

Author SHA1 Message Date
Joseph Doherty 8199553734 fix(worker): handle AckMsg add/advise/write COM failures and server death
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m55s
ci / java (push) Failing after 2m2s
ci / portable (push) Failing after 2m17s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:25:43 -04:00
Joseph Doherty 7900df3dce fix(worker): disambiguate MxStatusCategory in AlarmAckWriter
ci / nightly-windev (push) Has been skipped
ci / java (push) Failing after 2m25s
ci / portable (push) Failing after 2m26s
ci / windows-x86 (push) Successful in 2m27s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:22:27 -04:00
Joseph Doherty 76db822df3 feat(worker): acknowledge alarms via authenticated AckMsg write
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 56s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:21:55 -04:00
Joseph Doherty 64dc1215fd feat(worker): attribute alarm ack transitions from wnwrap ACK fields
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m7s
ci / java (push) Failing after 2m17s
ci / portable (push) Failing after 2m23s
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:18:24 -04:00
Joseph Doherty a94b502ce6 feat(contracts): carry operator credentials on alarm acknowledgement
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:16:39 -04:00
Joseph Doherty bf4b9c6c09 docs(plan): AckMsg-as-operator alarm acknowledgement plan
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-19 19:15:20 -04:00
Joseph Doherty 91bbdbf154 Merge branch 'docs/managed-alarm-ack-live-result' into main
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m14s
ci / windows-x86 (push) Successful in 2m23s
ci / portable (push) Failing after 2m32s
2026-10-18 15:15:58 -04:00
20 changed files with 3430 additions and 407 deletions
+30 -13
View File
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6;
string operator_domain = 7;
string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
}
message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// AckMsg write was applied or sent but unconfirmed; otherwise the
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// this payload exists so GatewayAlarmMonitor
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5;
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
}
message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
optional int32 hresult = 4;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -391,10 +391,13 @@ message AlarmFailoverConfig {
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
// AuthenticateUser as the operator, so the alarm history records the real
// operator. The reply's hresult / native_status is 0 when the write was
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
// the OnWriteComplete status detail.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
@@ -403,6 +406,10 @@ message AcknowledgeAlarmCommand {
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 7;
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
@@ -414,9 +421,7 @@ message QueryActiveAlarmsCommand {
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
// be resolved to a GUID directly. The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator.
message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot
@@ -431,6 +436,10 @@ message AcknowledgeAlarmByNameCommand {
string operator_node = 6;
string operator_domain = 7;
string operator_full_name = 8;
// Operator credential used for AuthenticateUser before the AckMsg write.
// Implementations must keep this field out of logs, metrics labels, command
// lines, and diagnostics.
string operator_password = 9;
}
message UnsubscribeBulkCommand {
@@ -725,11 +734,11 @@ message DrainEventsReply {
// Reply payload for AcknowledgeAlarmCommand AND
// AcknowledgeAlarmByNameCommand — both ack command kinds reuse this
// payload case (`MxCommandReply.acknowledge_alarm`); there is no
// dedicated by-name reply case. Surfaces AVEVA's native ack return
// code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the
// by-name arm); 0 means success. The MxCommandReply's hresult field
// dedicated by-name reply case. Surfaces the ack outcome: 0 when the
// AckMsg write was applied or sent but unconfirmed; otherwise the
// AuthenticateUser HRESULT or the OnWriteComplete status detail. The MxCommandReply's hresult field
// carries the same value and is preferred for protocol consumers —
// this payload exists so the gateway-side WorkerAlarmRpcDispatcher
// this payload exists so GatewayAlarmMonitor
// can echo native_status into AcknowledgeAlarmReply.hresult without
// unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
@@ -987,6 +996,15 @@ message AcknowledgeAlarmRequest {
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5;
// Operator's Galaxy (or OS) password. The gateway authenticates as
// operator_user with it before writing the acknowledgement so the alarm
// history records the real operator. May be empty for accounts without a
// password. Credential-sensitive: implementations must keep this field out
// of logs, metrics labels, command lines, and diagnostics.
string operator_password = 6;
// Optional domain for OS-authenticated galaxies. When set the gateway
// authenticates as "<operator_domain>\<operator_user>".
string operator_domain = 7;
}
message AcknowledgeAlarmReply {
@@ -997,8 +1015,7 @@ message AcknowledgeAlarmReply {
ProtocolStatus protocol_status = 3;
// Native ack return code echoed from the worker. The worker carries the
// ack outcome as a single int32 (AcknowledgeAlarmReplyPayload.native_status,
// = AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's
// WorkerAlarmRpcDispatcher copies that value here. This is the authoritative
// 0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies that value here. This is the authoritative
// ack-outcome field for the public RPC. Absent only when the worker reply
// omitted the value entirely (a protocol violation).
optional int32 hresult = 4;
@@ -22,4 +22,10 @@ public static class WorkerLaunchEnvironment
/// <summary>Maximum alarm records requested in one current-alarm fetch.</summary>
public const string MaxAlarmsPerFetch = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
/// <summary>
/// MXAccess client name the worker's alarm-ack writer registers with. The
/// Historian records it as the acknowledgement's OperatorNode.
/// </summary>
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
}
@@ -310,11 +310,47 @@ public sealed class ProtobufContractRoundTripTests
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "shift handover",
OperatorUser = "operator2",
OperatorPassword = "s3cret",
OperatorDomain = "CORP",
};
var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal("s3cret", parsed.OperatorPassword);
Assert.Equal("CORP", parsed.OperatorDomain);
}
/// <summary>Verifies that the worker ack commands round-trip the operator password.</summary>
[Fact]
public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
{
MxCommand byGuid = new()
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
OperatorUser = "alice",
OperatorDomain = "CORP",
OperatorPassword = "s3cret",
},
};
MxCommand byName = new()
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
OperatorUser = "alice",
OperatorPassword = "s3cret",
},
};
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
}
/// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary>
@@ -0,0 +1,262 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
public sealed class AlarmAckWriterTests
{
private static AlarmAckOperator Alice() =>
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
[Fact]
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
Assert.Equal(0, result.NativeStatus);
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
Assert.Equal("pw-secret", server.LastPassword);
}
[Fact]
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(1008, result.NativeStatus);
Assert.Single(result.Statuses);
}
[Fact]
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
Assert.Equal(0, result.NativeStatus);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
{
RecordingAckServer server = new RecordingAckServer
{
AuthenticateResult = 0,
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
};
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
Assert.NotEqual(0, result.NativeStatus);
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
}
[Fact]
public void Acknowledge_Twice_ReusesItemHandle()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
writer.Acknowledge("T.A.AckMsg", "one", Alice());
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
}
[Fact]
public void Acknowledge_WhenAdviseThrows_RejectsCleansUpAndReAddsNextTime()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, AdviseException = new COMException("x", unchecked((int)0x80004005)) };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
AlarmAckResult first = writer.Acknowledge("T.A.AckMsg", "one", Alice());
server.AdviseException = null;
writer.Acknowledge("T.A.AckMsg", "two", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, first.Outcome);
Assert.Equal(unchecked((int)0x80004005), first.NativeStatus);
Assert.DoesNotContain("pw-secret", first.Diagnostic, StringComparison.Ordinal);
Assert.Contains("RemoveItem", server.Operations);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenCachedWriteThrows_RejectsAndReAddsNextTime()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
writer.Acknowledge("T.A.AckMsg", "one", Alice());
server.WriteException = new COMException("x", unchecked((int)0x80004005));
AlarmAckResult failed = writer.Acknowledge("T.A.AckMsg", "two", Alice());
server.WriteException = null;
writer.Acknowledge("T.A.AckMsg", "three", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, failed.Outcome);
Assert.Equal(2, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
}
[Fact]
public void Acknowledge_WhenWriteDisconnected_TearsDownServer()
{
// The test seam has no COM factory, so re-registration itself is not
// exercised here; this verifies the teardown that makes it possible.
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5, WriteException = new COMException("x", unchecked((int)0x80010108)) };
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "one", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(unchecked((int)0x80010108), result.NativeStatus);
Assert.Contains("Unregister", server.Operations);
}
[Fact]
public void Acknowledge_WhenNonOkCompletionHasNoDetail_ReturnsMinusOne()
{
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 0));
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
Assert.Equal(-1, result.NativeStatus);
}
[Fact]
public void AlarmAckOperator_ToString_OmitsPassword()
{
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
}
}
internal sealed class RecordingAckServer : IMxAccessServer
{
private int nextItemHandle = 100;
public List<string> Operations { get; } = new();
public int AuthenticateResult { get; set; }
public Exception? AuthenticateException { get; set; }
public Exception? AdviseException { get; set; }
public Exception? WriteException { get; set; }
public string? LastPassword { get; private set; }
public Action<int, int>? OnWrite { get; set; }
public int Register(string clientName)
{
Operations.Add("Register:" + clientName);
return 7;
}
public void Unregister(int serverHandle) => Operations.Add("Unregister");
public int AddItem(int serverHandle, string itemDefinition)
{
Operations.Add("AddItem:" + itemDefinition);
return nextItemHandle++;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add("RemoveItem");
public void Advise(int serverHandle, int itemHandle)
{
Operations.Add("Advise");
if (AdviseException is not null)
{
throw AdviseException;
}
}
public void UnAdvise(int serverHandle, int itemHandle) => Operations.Add("UnAdvise");
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
}
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
Operations.Add("Write:" + value + ":" + userId);
if (WriteException is not null)
{
throw WriteException;
}
OnWrite?.Invoke(serverHandle, itemHandle);
}
public void Write2(int serverHandle, int itemHandle, object? value, object? timestamp, int userId)
{
}
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
}
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestamp)
{
}
public object Suspend(int serverHandle, int itemHandle) => new object();
public object Activate(int serverHandle, int itemHandle) => new object();
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
{
Operations.Add("AuthenticateUser:" + verifyUser);
LastPassword = verifyUserPassword;
if (AuthenticateException is not null)
{
throw AuthenticateException;
}
return AuthenticateResult;
}
public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0;
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
{
}
}
@@ -17,6 +17,38 @@ public sealed class AlarmDispatcherTests
{
private const string SessionId = "session-001";
/// <summary>Verifies an Acknowledge transition is attributed to the ACK_* operator and comment.</summary>
[Fact]
public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.UnackAlm,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "TestMachine_001.TestAlarm001",
State = MxAlarmStateKind.AckAlm,
AlarmComment = "Test alarm #1",
AckOperatorName = "alice",
AckComment = "checked",
},
});
Assert.True(queue.TryDequeue(out WorkerEvent? evt));
OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition;
Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
Assert.Equal("alice", body.OperatorUser);
Assert.Equal("checked", body.OperatorComment);
}
/// <summary>Verifies that alarm transitions land in the queue with correctly mapped fields.</summary>
[Fact]
public void OnTransition_WhenAlarmTransitionRaised_LandsInQueueWithMappedFields()
@@ -801,4 +801,27 @@ public sealed class WnWrapAlarmConsumerXmlTests
ProviderName = "Galaxy",
};
}
[Fact]
public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
{
const string xml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
"<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
"<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
"<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
"<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
"<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
MxAlarmSnapshotRecord record = Assert.Single(records.Values);
Assert.Equal("Administrator", record.AckOperatorName);
Assert.Equal("ArchestrA", record.AckOperatorDomain);
Assert.Equal("mxa", record.AckOperatorNode);
Assert.Equal("mgack-authwrite 180714", record.AckComment);
Assert.Equal("Test alarm #1", record.AlarmComment);
}
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Operator identity an alarm acknowledgement authenticates as. The password
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
/// and must never be logged or echoed.
/// </summary>
public sealed class AlarmAckOperator
{
/// <summary>Gets or sets the operator user name.</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
public string Domain { get; set; } = string.Empty;
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
/// <inheritdoc />
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
}
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
public enum AlarmAckOutcome
{
/// <summary>The AckMsg write completed with an OK status.</summary>
Applied,
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
Unconfirmed,
/// <summary>The alarm is not in the consumer's current view.</summary>
AlarmNotFound,
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
NotAckable,
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
AuthenticationFailed,
/// <summary>The AckMsg write completed with a non-OK status.</summary>
WriteRejected,
}
@@ -0,0 +1,49 @@
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Result of an alarm acknowledgement.</summary>
public sealed class AlarmAckResult
{
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
{
Outcome = outcome;
NativeStatus = nativeStatus;
Diagnostic = diagnostic;
Statuses = statuses ?? NoStatuses;
}
/// <summary>Gets the outcome.</summary>
public AlarmAckOutcome Outcome { get; }
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
public int NativeStatus { get; }
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
public string Diagnostic { get; }
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; }
/// <summary>Creates a result.</summary>
/// <param name="outcome">The outcome.</param>
/// <param name="nativeStatus">The native status.</param>
/// <param name="diagnostic">A credential-free diagnostic.</param>
/// <param name="statuses">Observed write statuses.</param>
/// <returns>The result.</returns>
public static AlarmAckResult Create(
AlarmAckOutcome outcome,
int nativeStatus,
string diagnostic,
IReadOnlyList<MxStatusProxy>? statuses = null) =>
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
/// <summary>Creates the result for an unresolved ack target.</summary>
/// <param name="target">The unresolved target.</param>
/// <returns>The result.</returns>
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
new AlarmAckResult(target.Failure, -1, target.Reason, null);
}
@@ -0,0 +1,42 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
public sealed class AlarmAckTarget
{
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
{
ItemAddress = itemAddress;
Failure = failure;
Reason = reason;
}
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
public bool Found => ItemAddress.Length > 0;
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
public string ItemAddress { get; }
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
public AlarmAckOutcome Failure { get; }
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
public string Reason { get; }
/// <summary>Creates a resolved target.</summary>
/// <param name="itemAddress">The ack item address.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget For(string itemAddress) =>
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
/// <summary>Creates a not-found result.</summary>
/// <param name="reason">Why the alarm was not found.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotFound(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
/// <summary>Creates a not-ackable result.</summary>
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
/// <returns>The target.</returns>
public static AlarmAckTarget NotAckable(string reason) =>
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
}
@@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using ArchestrA.MxAccess;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
/// AuthenticateUser as the operator, then write the comment to the alarm's
/// <c>AckMsg</c> attribute with that user id. Owns its own
/// <c>LMXProxyServerClass</c> registered under the configured client name,
/// which the Historian records as the acknowledgement's OperatorNode.
/// Apartment-threaded: every call must run on the worker STA. The password
/// is passed to MXAccess only; it never reaches diagnostics.
/// </summary>
public sealed class AlarmAckWriter : IAlarmAckWriter
{
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
public const string AckCommentAttribute = "AckMsg";
/// <summary>Default MXAccess client name when none is configured.</summary>
public const string DefaultClientName = "MxGateway";
private readonly IMxAccessComObjectFactory? factory;
private readonly MxAccessWriteCompletionCache completionCache;
private readonly Action pumpStep;
private readonly TimeSpan completionTimeout;
private readonly string clientName;
private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
private object? comObject;
private LMXProxyServerClass? comEventSource;
private IMxAccessServer? server;
private int serverHandle;
private bool disposed;
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
public AlarmAckWriter(
IMxAccessComObjectFactory factory,
Action pumpStep,
TimeSpan completionTimeout,
string? clientName)
{
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
completionCache = new MxAccessWriteCompletionCache();
}
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
internal AlarmAckWriter(
IMxAccessServer server,
MxAccessWriteCompletionCache completionCache,
Action pumpStep,
TimeSpan completionTimeout,
string clientName)
{
this.server = server ?? throw new ArgumentNullException(nameof(server));
this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
this.completionTimeout = completionTimeout;
this.clientName = clientName;
serverHandle = server.Register(clientName);
}
/// <summary>Resolves the configured client name from the launcher environment.</summary>
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
public static string ResolveClientName()
{
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
}
/// <inheritdoc />
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
IMxAccessServer mx = EnsureRegistered();
int userId;
try
{
userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
}
catch (COMException ex)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
ex.HResult,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
}
if (userId == 0)
{
return AlarmAckResult.Create(
AlarmAckOutcome.AuthenticationFailed,
-1,
$"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
}
ulong baselineVersion = 0;
int itemHandle = 0;
bool cached = itemHandles.TryGetValue(itemAddress, out itemHandle);
string step = "AddItem";
try
{
if (!cached)
{
itemHandle = mx.AddItem(serverHandle, itemAddress);
itemHandles[itemAddress] = itemHandle;
step = "Advise";
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
mx.Advise(serverHandle, itemHandle);
}
pumpStep();
step = "Write";
baselineVersion = completionCache.CurrentVersion(serverHandle, itemHandle);
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
}
catch (COMException ex)
{
int hresult = ex.HResult;
if (itemHandles.Remove(itemAddress))
{
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
}
if (IsServerGone(hresult))
{
TearDownServer();
}
// Diagnostic names the step and HRESULT only: exception text can echo the item or operator.
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
hresult,
$"AckMsg {step} failed (HRESULT 0x{hresult:X8}).");
}
ulong baseline = baselineVersion;
if (!completionCache.TryWaitForCompletion(
serverHandle,
itemHandle,
baseline,
DateTime.UtcNow + completionTimeout,
pumpStep,
out RepeatedField<MxStatusProxy> statuses))
{
return AlarmAckResult.Create(
AlarmAckOutcome.Unconfirmed,
0,
$"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
}
foreach (MxStatusProxy status in statuses)
{
if (status.Category != Contracts.Proto.MxStatusCategory.Ok)
{
return AlarmAckResult.Create(
AlarmAckOutcome.WriteRejected,
status.Detail != 0 ? status.Detail : -1,
$"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
statuses);
}
}
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
TearDownServer();
}
private static bool IsServerGone(int hresult) =>
hresult == unchecked((int)0x80010108) // RPC_E_DISCONNECTED
|| hresult == unchecked((int)0x800706BA) // RPC_S_SERVER_UNAVAILABLE
|| hresult == unchecked((int)0x80010105); // RPC_E_SERVERFAULT
/// <summary>Releases the MXAccess registration so the next call re-registers (also used by Dispose).</summary>
private void TearDownServer()
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
}
IMxAccessServer? mx = server;
if (mx is not null)
{
foreach (int itemHandle in itemHandles.Values)
{
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
}
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
}
itemHandles.Clear();
server = null;
comEventSource = null;
object? toRelease = comObject;
comObject = null;
if (toRelease is not null && Marshal.IsComObject(toRelease))
{
try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
}
}
private IMxAccessServer EnsureRegistered()
{
if (server is not null) return server;
if (factory is null)
{
throw new InvalidOperationException("MXAccess server is gone and no COM factory is available to re-register.");
}
object created = factory.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
try
{
comObject = created;
if (created is LMXProxyServerClass comServer)
{
comEventSource = comServer;
comServer.OnWriteComplete += OnWriteComplete;
}
IMxAccessServer mx = new MxAccessComServer(created);
serverHandle = mx.Register(clientName);
server = mx;
return mx;
}
catch
{
if (comEventSource is not null)
{
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
comEventSource = null;
}
if (Marshal.IsComObject(created))
{
try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
}
comObject = null;
throw;
}
}
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
{
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
rows.AddRange(statusConverter.ConvertMany(pVars));
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
}
}
@@ -244,6 +244,7 @@ public sealed class AlarmDispatcher : IDisposable
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName);
bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
sink.EnqueueTransition(
alarmFullReference: fullReference,
@@ -253,15 +254,22 @@ public sealed class AlarmDispatcher : IDisposable
severity: record.Priority,
originalRaiseTimestampUtc: null,
transitionTimestampUtc: record.TransitionTimestampUtc,
operatorUser: record.OperatorName,
operatorComment: record.AlarmComment,
operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
operatorComment: useAck ? record.AckComment : record.AlarmComment,
category: record.Group,
description: string.Empty,
degraded: record.Degraded);
}
private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
!string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);
private static bool IsAcked(MxAlarmStateKind state) =>
state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated)
{
bool useAck = IsAcked(record.State) && HasAckAttribution(record);
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
{
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
@@ -270,8 +278,8 @@ public sealed class AlarmDispatcher : IDisposable
AlarmTypeName = record.Type,
CurrentState = MapConditionState(record.State),
Severity = record.Priority,
OperatorUser = record.OperatorName,
OperatorComment = record.AlarmComment,
OperatorUser = useAck ? record.AckOperatorName : record.OperatorName,
OperatorComment = useAck ? record.AckComment : record.AlarmComment,
Category = record.Group,
Description = string.Empty,
Degraded = record.Degraded,
@@ -0,0 +1,17 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
/// after authenticating as the operator. Must be called on the worker STA.
/// </summary>
public interface IAlarmAckWriter : IDisposable
{
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
/// <param name="comment">The acknowledgement comment.</param>
/// <param name="ackOperator">The operator to authenticate as.</param>
/// <returns>The outcome.</returns>
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
}
@@ -39,6 +39,18 @@ public sealed class MxAlarmSnapshotRecord
public string OperatorName { get; set; } = string.Empty;
/// <summary>Gets or sets the alarm comment.</summary>
public string AlarmComment { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledgement comment (wnwrap <c>ACK_COMMENT</c>).</summary>
public string AckComment { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging operator's name (wnwrap <c>ACK_OPERATOR_NAME</c>).</summary>
public string AckOperatorName { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging operator's domain (wnwrap <c>ACK_DOMAIN_NAME</c>).</summary>
public string AckOperatorDomain { get; set; } = string.Empty;
/// <summary>Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap <c>ACK_OPERATOR_NODE</c>).</summary>
public string AckOperatorNode { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this record was synthesized
/// by the subtag-provider fallback rather than emitted by the native
@@ -792,6 +792,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
string? operatorNode = null;
string? operatorName = null;
string? alarmComment = null;
string? ackComment = null;
string? ackOperatorName = null;
string? ackOperatorDomain = null;
string? ackOperatorNode = null;
foreach (XmlNode child in alarmNode.ChildNodes)
{
@@ -816,6 +820,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
case "ACK_COMMENT": ackComment ??= child.InnerText; break;
case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;
}
}
@@ -843,6 +851,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
OperatorNode = operatorNode ?? string.Empty,
OperatorName = operatorName ?? string.Empty,
AlarmComment = alarmComment ?? string.Empty,
AckComment = ackComment ?? string.Empty,
AckOperatorName = ackOperatorName ?? string.Empty,
AckOperatorDomain = ackOperatorDomain ?? string.Empty,
AckOperatorNode = ackOperatorNode ?? string.Empty,
};
}
return records;