Compare commits

...

9 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
Joseph Doherty 8c095b9a56 Record the managed GUID alarm-ack live result
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m25s
ci / java (push) Successful in 2m18s
ci / portable (push) Failing after 2m39s
The 2026-10-18 live test on the dev rig showed the opt-in managed ack path
cannot work as built: AlarmClient.RegisterConsumer returns -65 alongside the
worker's wnwrap reader (the worker then silently falls back to wnwrap), the
managed client does not recognise the wnwrap GUID even standalone, and the
gateway's GUID arm sends empty identity fields. Replace the docs' claim that
the path works with the measured results and tell operators to leave
MXGATEWAY_USE_MANAGED_ALARM_ACK unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:11:11 -04:00
Joseph Doherty 6c82de55c6 Merge pull request 'Add opt-in managed GUID alarm-ack path' (#160) from feat/managed-guid-alarm-ack into main
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m59s
ci / portable (push) Failing after 2m21s
ci / java (push) Successful in 2m7s
2026-10-18 13:12:12 -04:00
22 changed files with 3482 additions and 431 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;
+46 -19
View File
@@ -793,27 +793,54 @@ in the codebase for the forward-compat shape, but the gateway-side
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
`Provider!Group.Tag` reference.
**Opt-in managed GUID ack.** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1`
in the gateway's environment (workers inherit it) routes GUID-keyed acks
through `aaAlarmManagedClient.AlarmClient.AlarmAckByGUID` instead of the
wnwrap method. That managed method is implemented on this build and
carries operator domain and full name natively. The worker reaches it by
reflection (`AaManagedAlarmAckConsumer`) because the members are
non-public, and runs its own Initialize → Register → Subscribe lifecycle
against the alarm subscription expression. The path is deliberately
conservative:
**Opt-in managed GUID ack — does not work on this rig (live-tested
2026-10-18).** Setting `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` in the
gateway's environment (workers inherit it) makes the worker build an
`AaManagedAlarmAckConsumer`, which drives
`aaAlarmManagedClient.AlarmClient` by reflection (its members are
non-public) and is meant to take GUID-keyed acks in place of the
wnwrap method. The live test shows it cannot do that as built, so leave
the flag off.
- If the DLL cannot load or the lifecycle returns a non-zero status, the
worker drops back to the wnwrap ack path; alarm reads never fail
because of it.
- Subtag-mode acks (forced subtag, or failover currently on the standby)
stay on the subtag consumer: their GUIDs are synthetic and only it can
resolve them.
- By-name acks stay on wnwrap — `AlarmClient` has no by-name ack.
The test raised `TestMachine_001.TestAlarm001` with a secured write,
read its GUID from the wnwrap XML snapshot (the public API exposes no
alarm GUIDs), and acknowledged it through the `AcknowledgeAlarm` RPC
with the flag on:
A zero return still means *accepted*, not *applied*; a secured
(`MxSecurityOperate`) alarm needs an authenticated operator. The skip-gated
`AlarmManagedGuidAckLiveSmokeTests` asserts the ack actually lands.
| Step | Result |
|---|---|
| RPC ack by GUID, flag off (control) | `NotImplementedException`; `.Acked` stays `False` |
| RPC ack by GUID, flag on | Same `NotImplementedException`; `.Acked` `False`, `.AckMsg` empty |
| `AlarmClient.RegisterConsumer` in a process with a registered wnwrap consumer | `-65` — and wnwrap gets `-65` if the managed client registered first; a real `hWnd` does not help |
| Managed client alone in its own process, records loaded | `AlarmAckByGUID` → `-1`, `SelectByGUID` → `-1` for the wnwrap GUID in both byte orders, `AlarmAckSelected` → `-57` |
Three separate problems follow:
1. **The two libraries cannot share a process.** The worker always runs
a wnwrap reader, so the managed `RegisterConsumer` fails with `-65`.
The dispatcher is built to drop back to the wnwrap ack path on a
lifecycle failure (so alarm reads keep working), and it does so
silently — which is why the flag-on RPC is indistinguishable from the
control.
2. **The managed client does not recognise the wnwrap GUID.** `-1` from
`SelectByGUID` is "not found", not a security refusal, even with the
client holding records. Getting the client's own identifier for the
alarm is not reliable either: `GetHighPriAlarm` mostly throws the
FILETIME `ArgumentOutOfRangeException` described above. The
`MxSecurityOperate` question was therefore never reached.
3. **The gateway does not send the identity fields.** Its GUID arm fills
`OperatorNode`, `OperatorDomain` and `OperatorFullName` with empty
strings, so the domain and full name the managed method carries
would not arrive through the public RPC even if 1 and 2 were solved.
A working managed ack would need the client hosted out of process, a way
to resolve the alarm to the identifier the managed client uses, and the
identity fields plumbed through the gateway. Routing rules the worker
already applies stay correct and would carry over: subtag-mode acks
(synthetic GUIDs) stay on the subtag consumer, and by-name acks stay on
wnwrap because `AlarmClient` has no by-name ack. The skip-gated
`AlarmManagedGuidAckLiveSmokeTests` exercises the in-process
arrangement and hits problem 1.
### 5. STA / threading — resolved
+6 -5
View File
@@ -446,11 +446,12 @@ behavior.
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
`StreamAlarms` are session-less RPCs served by the monitor.
GUID-keyed acknowledges can opt into AVEVA's managed alarm client by setting
the environment variable `MXGATEWAY_USE_MANAGED_ALARM_ACK=1` on the gateway
process (workers inherit it; under NSSM, add it to the service environment).
It is off by default and has no `MxGateway:*` option because it is still
awaiting live confirmation on the rig. See
The worker also reads `MXGATEWAY_USE_MANAGED_ALARM_ACK` (inherited from the
gateway process). Setting it to `1` is meant to route GUID-keyed acknowledges
through AVEVA's managed alarm client, but a live test (2026-10-18) showed the
path cannot work as built: the managed client fails to register alongside the
worker's wnwrap reader and the worker silently falls back to wnwrap. Leave it
unset. It has no `MxGateway:*` option. See
[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
### Alarm fallback options
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;