diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md
index 6caa43f..7878f81 100644
--- a/docs/AlarmClientDiscovery.md
+++ b/docs/AlarmClientDiscovery.md
@@ -793,6 +793,28 @@ 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:
+
+- 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.
+
+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.
+
### 5. STA / threading — resolved
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md
index b80266e..4b0c0af 100644
--- a/docs/GatewayConfiguration.md
+++ b/docs/GatewayConfiguration.md
@@ -446,6 +446,13 @@ 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
+[AlarmClientDiscovery](./AlarmClientDiscovery.md) ("Opt-in managed GUID ack").
+
### Alarm fallback options
The `Fallback` sub-section controls how the alarm feed selects between the
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
index d41b42f..7c85796 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs
@@ -203,6 +203,100 @@ public sealed class AlarmDispatcherTests
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
}
+ /// Verifies that a managed ack client takes GUID acks with full operator identity.
+ [Fact]
+ public void Acknowledge_WithManagedAckClient_RoutesGuidAckToManagedClient()
+ {
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = -1 };
+ FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = 0 };
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ consumer,
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId,
+ managed);
+ dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
+
+ Guid guid = Guid.NewGuid();
+ int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+
+ Assert.Equal(0, rc);
+ Assert.Equal(@"\\HOST\Galaxy!Area1", managed.LastSubscription);
+ Assert.Equal(guid, managed.LastAckGuid);
+ Assert.Equal("CORP", managed.LastAckOperatorDomain);
+ Assert.Equal("Alice Smith", managed.LastAckOperatorFullName);
+ Assert.Equal(Guid.Empty, consumer.LastAckGuid);
+ }
+
+ ///
+ /// Verifies that a failed managed-client lifecycle neither fails the alarm
+ /// subscription nor leaves acks routed to the broken client.
+ ///
+ [Fact]
+ public void Subscribe_WhenManagedAckClientFails_FallsBackToConsumerAck()
+ {
+ FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
+ FakeManagedAckClient managed = new FakeManagedAckClient { ThrowOnSubscribe = true };
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ consumer,
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId,
+ managed);
+
+ dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
+ Guid guid = Guid.NewGuid();
+ int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+
+ Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
+ Assert.True(managed.Disposed);
+ Assert.Equal(0, rc);
+ Assert.Equal(guid, consumer.LastAckGuid);
+ Assert.Equal(Guid.Empty, managed.LastAckGuid);
+ }
+
+ ///
+ /// Verifies that while the failover consumer serves the subtag standby,
+ /// GUID acks (synthetic GUIDs) stay on the consumer, not the managed client.
+ ///
+ [Fact]
+ public void Acknowledge_WhenFailoverInSubtagMode_KeepsGuidAckOnConsumer()
+ {
+ FakeAlarmConsumer primary = new FakeAlarmConsumer { ThrowOnSubscribe = true };
+ FakeAlarmConsumer standby = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
+ FailoverAlarmConsumer failover = new FailoverAlarmConsumer(
+ primary, standby, new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 3));
+ FakeManagedAckClient managed = new FakeManagedAckClient { AcknowledgeReturn = -1 };
+ using AlarmDispatcher dispatcher = new AlarmDispatcher(
+ failover,
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId,
+ managed);
+ dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
+ Assert.Equal(AlarmProviderMode.Subtag, failover.Mode);
+
+ Guid guid = Guid.NewGuid();
+ int rc = dispatcher.Acknowledge(guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
+
+ Assert.Equal(0, rc);
+ Assert.Equal(guid, standby.LastAckGuid);
+ Assert.Equal(Guid.Empty, managed.LastAckGuid);
+ }
+
+ /// Verifies that disposing the dispatcher disposes the managed ack client.
+ [Fact]
+ public void Dispose_WithManagedAckClient_DisposesManagedClient()
+ {
+ FakeManagedAckClient managed = new FakeManagedAckClient();
+ AlarmDispatcher dispatcher = new AlarmDispatcher(
+ new FakeAlarmConsumer(),
+ new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
+ SessionId,
+ managed);
+
+ dispatcher.Dispose();
+
+ Assert.True(managed.Disposed);
+ }
+
/// Verifies that acknowledge-by-name calls are forwarded to the consumer.
[Fact]
public void AcknowledgeByName_WhenInvoked_ForwardsToConsumerWithFullTuple()
@@ -470,9 +564,13 @@ public sealed class AlarmDispatcherTests
AlarmTransitionEmitted?.Invoke(this, transition);
}
+ /// Gets or sets a value indicating whether throws.
+ public bool ThrowOnSubscribe { get; set; }
+
///
public void Subscribe(string subscription)
{
+ if (ThrowOnSubscribe) throw new InvalidOperationException("subscribe failed");
LastSubscription = subscription;
}
@@ -533,4 +631,50 @@ public sealed class AlarmDispatcherTests
Disposed = true;
}
}
+
+ private sealed class FakeManagedAckClient : IManagedAlarmAckClient
+ {
+ /// Gets the last subscription expression.
+ public string? LastSubscription { get; private set; }
+ /// Gets the GUID from the last acknowledge call.
+ public Guid LastAckGuid { get; private set; }
+ /// Gets the operator domain from the last acknowledge call.
+ public string? LastAckOperatorDomain { get; private set; }
+ /// Gets the operator full name from the last acknowledge call.
+ public string? LastAckOperatorFullName { get; private set; }
+ /// Gets or sets the return code for acknowledge operations.
+ public int AcknowledgeReturn { get; set; }
+ /// Gets or sets a value indicating whether throws.
+ public bool ThrowOnSubscribe { get; set; }
+ /// Gets a value indicating whether this instance has been disposed.
+ public bool Disposed { get; private set; }
+
+ ///
+ public void Subscribe(string subscription)
+ {
+ if (ThrowOnSubscribe) throw new InvalidOperationException("RegisterConsumer returned 5.");
+ LastSubscription = subscription;
+ }
+
+ ///
+ public int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string comment,
+ string operatorName,
+ string operatorNode,
+ string operatorDomain,
+ string operatorFullName)
+ {
+ LastAckGuid = alarmGuid;
+ LastAckOperatorDomain = operatorDomain;
+ LastAckOperatorFullName = operatorFullName;
+ return AcknowledgeReturn;
+ }
+
+ ///
+ public void Dispose()
+ {
+ Disposed = true;
+ }
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
new file mode 100644
index 0000000..f21a93f
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.MxAccess;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
+
+///
+/// Live dev-rig smoke test for the MANAGED GUID-ack path — the
+/// wired into
+/// . Where AlarmsLiveSmokeTests acks by
+/// name through the wnwrap COM consumer, this acks by GUID through AVEVA's
+/// modern aaAlarmManagedClient.AlarmClient.AlarmAckByGUID — the path
+/// that carries operator domain + full name and is not E_NOTIMPL.
+///
+/// Flow: subscribe a wnwrap reader + a managed ack client to
+/// \\<machine>\Galaxy!DEV; wait for a Raise; pull a real
+/// (non-degraded) alarm GUID from the reader's snapshot; ack it by GUID via
+/// the dispatcher (which routes to the managed client); then assert the ack
+/// registers as a subsequent
+/// transition (and that the active snapshot shows the alarm acked).
+///
+/// This is the runtime confirmation the static work could not close: that an
+/// authenticated GUID ack actually flips state. rc=0 alone is "accepted",
+/// not "applied" — the Acknowledge transition is the real assertion. For a
+/// SECURED alarm the ack must run under an operator with Operate permission;
+/// run the dev-rig flip script and, if the test UDA is secured, an
+/// authenticated operator session (see docs/AlarmProbeFindings.md).
+///
+/// Skip-gated; flip Skip=null on the dev rig with AVEVA +
+/// aaAlarmManagedClient.dll present and the flip script running.
+///
+public sealed class AlarmManagedGuidAckLiveSmokeTests
+{
+ private static readonly string SubscriptionExpression =
+ $@"\\{Environment.MachineName}\Galaxy!DEV";
+ private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
+ private static readonly TimeSpan RaiseWait = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan AckConfirmWait = TimeSpan.FromSeconds(30);
+
+ private const string SessionId = "alarms-managed-guid-ack-smoke";
+
+ private readonly ITestOutputHelper output;
+ private readonly Stopwatch elapsed = Stopwatch.StartNew();
+ private readonly ConcurrentQueue log = new ConcurrentQueue();
+
+ /// Initializes a new instance of the AlarmManagedGuidAckLiveSmokeTests class.
+ /// Test output helper for logging.
+ public AlarmManagedGuidAckLiveSmokeTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ /// Acks a live alarm by GUID through the managed client and verifies the acked transition.
+ [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + aaAlarmManagedClient.dll + the alarm flip script, and an operator authorized to ack.")]
+ public void ManagedGuidAck_AcksActiveAlarm_RegistersAcknowledge()
+ {
+ Exception? threadException = null;
+ var done = new ManualResetEventSlim(false);
+ var thread = new Thread(() =>
+ {
+ try { RunSmoke(); }
+ catch (Exception ex) { threadException = ex; }
+ finally { done.Set(); }
+ });
+ thread.IsBackground = false;
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ done.Wait();
+ thread.Join();
+
+ output.WriteLine($"Captured {log.Count} log line(s):");
+ while (log.TryDequeue(out string? line)) output.WriteLine(line);
+
+ if (threadException != null) throw threadException;
+ }
+
+ private void RunSmoke()
+ {
+ Log($"Subscription expression: {SubscriptionExpression}");
+
+ MxAccessEventQueue queue = new MxAccessEventQueue();
+ WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
+ new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
+ maxAlarmsPerFetch: 1024);
+ MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
+
+ // Construct the managed ack client directly (production selects it via
+ // AaManagedAlarmAckConsumer.EnvGatedFactory when MXGATEWAY_USE_MANAGED_ALARM_ACK=1).
+ using IManagedAlarmAckClient managedAck =
+ new AaManagedAlarmAckConsumer(SessionId + ".ack");
+ using AlarmDispatcher dispatcher =
+ new AlarmDispatcher(consumer, sink, SessionId, managedAck);
+
+ Log("Constructed reader + managed ack client + dispatcher.");
+ dispatcher.Subscribe(SubscriptionExpression);
+ Log("Subscribe (reader + managed ack) -> ok. Driving PollOnce from this STA...");
+
+ // 1. Wait for a Raise so there is an active, unacked alarm to ack.
+ OnAlarmTransitionEvent? raiseBody = null;
+ DateTime raiseDeadline = DateTime.UtcNow + RaiseWait;
+ while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
+ {
+ WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
+ if (evt is null) break;
+ OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
+ Log("Transition: " + DescribeTransition(body));
+ Assert.Equal(SessionId, evt.Event.SessionId);
+ if (body.TransitionKind == AlarmTransitionKind.Raise) raiseBody = body;
+ }
+ Assert.NotNull(raiseBody);
+
+ // 2. Pull a REAL alarm GUID from the reader's snapshot (alarmmgr mode).
+ // Skip degraded/subtag records — their GUID is synthetic and not
+ // valid for AlarmAckByGUID.
+ var records = consumer.SnapshotActiveAlarms(out _);
+ Log($"Snapshot records: {records.Count}");
+ foreach (var r in records)
+ Log($" rec guid={r.AlarmGuid} state={r.State} degraded={r.Degraded} ref={r.ProviderName}!{r.Group}.{r.TagName}");
+
+ MxAlarmSnapshotRecord? target = records.FirstOrDefault(r =>
+ !r.Degraded &&
+ r.AlarmGuid != Guid.Empty &&
+ r.State == MxAlarmStateKind.UnackAlm);
+ Assert.NotNull(target);
+ Log($"Ack target GUID={target!.AlarmGuid} ref={target.ProviderName}!{target.Group}.{target.TagName}");
+
+ // 3. Ack by GUID through the dispatcher -> managed AlarmAckByGUID, with
+ // the current Windows identity (domain + full name are carried
+ // natively on this path, unlike the wnwrap 6-arg AckByName).
+ string user = Environment.UserName;
+ string node = Environment.MachineName;
+ string domain = Environment.UserDomainName ?? string.Empty;
+ Log($"Ack identity user='{user}' node='{node}' domain='{domain}'");
+
+ int rc = dispatcher.Acknowledge(
+ alarmGuid: target.AlarmGuid,
+ ackComment: "managed-guid-ack smoke",
+ ackOperatorName: user,
+ ackOperatorNode: node,
+ ackOperatorDomain: domain,
+ ackOperatorFullName: user);
+ Log($"dispatcher.Acknowledge(managed GUID) -> rc={rc}");
+ Assert.Equal(0, rc);
+
+ // 4. The ack must APPLY, not merely be accepted: wait for an
+ // Acknowledge transition on the same alarm, or the snapshot showing
+ // it acked. rc=0 with neither is the inert-ack failure the probe saw.
+ bool acked = false;
+ DateTime confirmDeadline = DateTime.UtcNow + AckConfirmWait;
+ while (DateTime.UtcNow < confirmDeadline && !acked)
+ {
+ WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
+ if (evt is not null)
+ {
+ OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
+ Log("Post-ack transition: " + DescribeTransition(body));
+ if (body.TransitionKind == AlarmTransitionKind.Acknowledge)
+ {
+ acked = true;
+ break;
+ }
+ }
+
+ // Also accept the active snapshot flipping to an acked state for the
+ // target GUID, in case the ack delta was folded without a discrete
+ // Acknowledge transition.
+ var now = consumer.SnapshotActiveAlarms(out _);
+ if (now.Any(r => r.AlarmGuid == target.AlarmGuid &&
+ (r.State == MxAlarmStateKind.AckAlm || !string.IsNullOrEmpty(r.OperatorName))))
+ {
+ Log("Snapshot shows target GUID acked (AckAlm / operator populated).");
+ acked = true;
+ }
+ }
+
+ Assert.True(
+ acked,
+ "Managed GUID ack returned rc=0 but no Acknowledge transition or acked snapshot was observed " +
+ "within the confirm window — the ack was accepted but not applied (see docs/AlarmProbeFindings.md; " +
+ "most likely the alarm's MxSecurityOperate classification requires an authenticated operator).");
+ }
+
+ private WorkerEvent? WaitForTransition(
+ MxAccessEventQueue queue,
+ TimeSpan timeout,
+ string label,
+ WnWrapAlarmConsumer consumer)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ int pollCount = 0;
+ while (DateTime.UtcNow < deadline)
+ {
+ try
+ {
+ consumer.PollOnce();
+ pollCount++;
+ }
+ catch (Exception ex)
+ {
+ Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
+ throw;
+ }
+ if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
+ {
+ if (evt.Event.Family == MxEventFamily.OnAlarmTransition) return evt;
+ Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
+ }
+ Thread.Sleep(500);
+ }
+ Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (polls={pollCount}).");
+ return null;
+ }
+
+ private static string DescribeTransition(OnAlarmTransitionEvent body)
+ {
+ return string.Format(
+ "kind={0} ref='{1}' operator='{2}' comment='{3}' ts={4:o}",
+ body.TransitionKind, body.AlarmFullReference, body.OperatorUser, body.OperatorComment,
+ body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
+ }
+
+ private void Log(string line) => log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
index c9afa8d..44da620 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
options,
() => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger,
stream)
{
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
options,
processIdProvider,
new WorkerPipeSessionOptions(),
- () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
+ () => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
logger: null)
{
}
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
// parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call.
_runtimeSession ??= new MxAccessStaSession(
- (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
+ (eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory));
IWorkerRuntimeSession session = _runtimeSession;
try
{
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
new file mode 100644
index 0000000..8c68f9f
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs
@@ -0,0 +1,231 @@
+using System;
+using System.IO;
+using System.Reflection;
+using WNWRAPCONSUMERLib;
+
+namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
+
+///
+/// Acknowledge path over AVEVA's modern managed alarm client,
+/// aaAlarmManagedClient.AlarmClient (the C++/CLI assembly the AVEVA
+/// Alarm Control itself uses: AlarmClient → CwwAlarmConsumer →
+/// alarmlst.dll → alarmmgr.exe). Preferred over a hand-rolled native
+/// WNAL consumer because AlarmAckByGUID performs the
+/// register → match-by-GUID → submit sequence internally — the record
+/// layout and selection semantics that are provider-defined (and therefore
+/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's
+/// own code. See for why the native
+/// acquisition path is deliberately unfinished.
+///
+/// The AlarmClient members are famorasm (protected-internal)
+/// and mxaccessgw is not an InternalsVisibleTo friend, so everything
+/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is
+/// permitted. Method shapes are confirmed from the assembly metadata:
+/// .ctor() (internal)
+/// int InitializeConsumer(string appName)
+/// int RegisterConsumer(int hWnd, string product, string app, string version, bool)
+/// int Subscribe(string expr, short fromPri, short toPri,
+/// localQueryType, localSortFlags,
+/// localAlarmFilterState mask, localAlarmFilterState spec)
+/// int AlarmAckByGUID(System.Guid guid, string comment, string oper,
+/// string node, string domain, string fullName)
+/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose()
+///
+/// THREADING: the underlying consumer is apartment-affine (same as the
+/// wnwrap COM path). Construct and call this only on the worker STA
+/// (marshal via StaRuntime.InvokeAsync).
+///
+/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
+///
+/// OPEN (confirm at runtime, flagged inline): the trailing bool of
+/// RegisterConsumer (passed false here); whether
+/// Subscribe is actually required before an ack by GUID; and the
+/// native status returned on a real secured ack.
+///
+public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
+{
+ /// Default install location of the managed alarm client (eac.dll folder).
+ public const string DefaultAssemblyPath =
+ @"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
+
+ ///
+ /// Env var that opts the worker into the managed ack path. Unset/anything
+ /// other than "1" keeps the existing wnwrap ack behavior.
+ ///
+ public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
+
+ private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
+
+ ///
+ /// Production factory for the seam:
+ /// returns a managed ack client only when == "1",
+ /// otherwise (ack stays on the wnwrap path). Never
+ /// throws — a construction failure (missing DLL, etc.) degrades to the
+ /// wnwrap path rather than breaking the alarm subscription.
+ ///
+ public static IManagedAlarmAckClient? EnvGatedFactory()
+ {
+ if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal))
+ return null;
+ try
+ {
+ return new AaManagedAlarmAckConsumer(AckApplicationName);
+ }
+ catch
+ {
+ // Opt-in enhancement only: if the managed client can't load, fall
+ // back to the wnwrap ack path rather than failing alarm reads.
+ return null;
+ }
+ }
+
+ private const string TypeName = "aaAlarmManagedClient.AlarmClient";
+ private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+
+ private readonly object syncRoot = new();
+ private readonly object client;
+ private readonly MethodInfo initializeConsumer;
+ private readonly MethodInfo registerConsumer;
+ private readonly MethodInfo subscribe;
+ private readonly MethodInfo alarmAckByGuid;
+ private readonly MethodInfo? deregisterConsumer;
+ private readonly MethodInfo? uninitializeConsumer;
+
+ private bool subscribed;
+ private bool disposed;
+
+ /// Consumer application name (e.g. "MxGateway.ack").
+ /// Override for .
+ public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null)
+ {
+ if (string.IsNullOrWhiteSpace(applicationName))
+ throw new ArgumentException("Application name is required.", nameof(applicationName));
+
+ string path = assemblyPath ?? DefaultAssemblyPath;
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path);
+
+ Assembly asm = Assembly.LoadFrom(path);
+ Type type = asm.GetType(TypeName, throwOnError: true)
+ ?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}.");
+
+ initializeConsumer = Resolve(type, "InitializeConsumer");
+ registerConsumer = Resolve(type, "RegisterConsumer");
+ subscribe = Resolve(type, "Subscribe");
+ alarmAckByGuid = Resolve(type, "AlarmAckByGUID");
+ deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null);
+ uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null);
+
+ client = Activator.CreateInstance(type, nonPublic: true)
+ ?? throw new InvalidOperationException($"Could not construct '{TypeName}'.");
+
+ ApplicationName = applicationName;
+ }
+
+ /// The consumer application name this instance registered under.
+ public string ApplicationName { get; }
+
+ private static MethodInfo Resolve(Type type, string name) =>
+ type.GetMethod(name, Inst)
+ ?? throw new MissingMethodException(TypeName, name);
+
+ ///
+ /// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
+ /// proven wnwrap argument values. is the
+ /// alarm query root, e.g. \\NODE\Galaxy!Area. Throws if any native
+ /// status is non-zero.
+ ///
+ public void Subscribe(string subscription)
+ {
+ if (subscription is null) throw new ArgumentNullException(nameof(subscription));
+ lock (syncRoot)
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
+ if (subscribed) throw new InvalidOperationException("Subscribe was already called.");
+
+ int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!;
+ if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}.");
+
+ // RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model.
+ // The trailing bool is unconfirmed; false = client/non-modal is the safe default.
+ int reg = (int)registerConsumer.Invoke(client, new object[]
+ {
+ 0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false,
+ })!;
+ if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}.");
+
+ int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!;
+ if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}.");
+
+ subscribed = true;
+ }
+ }
+
+ ///
+ /// Acknowledge a single alarm by GUID with full operator identity. Returns
+ /// AVEVA's native status (0 = success). A zero return means accepted, not
+ /// necessarily applied — verify the alarm's acked state / Historian row
+ /// (see docs/AlarmProbeFindings.md).
+ ///
+ public int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string comment,
+ string operatorName,
+ string operatorNode,
+ string operatorDomain,
+ string operatorFullName)
+ {
+ lock (syncRoot)
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
+ if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging.");
+
+ return (int)alarmAckByGuid.Invoke(client, new object[]
+ {
+ alarmGuid,
+ comment ?? string.Empty,
+ operatorName ?? string.Empty,
+ operatorNode ?? string.Empty,
+ operatorDomain ?? string.Empty,
+ operatorFullName ?? string.Empty,
+ })!;
+ }
+ }
+
+ ///
+ /// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
+ /// aaAlarmManagedClient's own enum parameter types (localQueryType /
+ /// localSortFlags / localAlarmFilterState) via .
+ ///
+ private object[] BuildSubscribeArgs(string subscription)
+ {
+ ParameterInfo[] p = subscribe.GetParameters();
+ if (p.Length != 7)
+ throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7.");
+
+ return new object[]
+ {
+ subscription,
+ (short)1, // wFromPri
+ (short)999, // wToPri
+ Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary),
+ Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst),
+ Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
+ Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
+ };
+ }
+
+ ///
+ public void Dispose()
+ {
+ lock (syncRoot)
+ {
+ if (disposed) return;
+ disposed = true;
+
+ try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty