diff --git a/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs b/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs
index 6f4cc2d..892f36a 100644
--- a/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs
+++ b/src/MxGateway.Worker.Tests/AlarmClientDiscoveryTests.cs
@@ -55,5 +55,40 @@ public sealed class AlarmClientDiscoveryTests
output.WriteLine($" method {m.ReturnType.Name} {m.Name}({parms})");
}
}
+
+ // Probe AlarmRecord + enum types reachable from AlarmClient's module —
+ // these are typically internal to the assembly but referenced by the
+ // public API methods we just dumped.
+ output.WriteLine("");
+ output.WriteLine("Reachable types in the AlarmClient module:");
+ Type alarmClient = asm.GetType("aaAlarmManagedClient.AlarmClient")!;
+ foreach (Type t in alarmClient.Module.GetTypes()
+ .Where(t => !t.IsNested)
+ .OrderBy(t => t.FullName, StringComparer.Ordinal))
+ {
+ string visibility = t.IsPublic ? "public" : "internal";
+ string kind = t.IsEnum ? "enum" : t.IsValueType ? "struct" : t.IsInterface ? "interface" : "class";
+ output.WriteLine($" {visibility} {kind} {t.FullName}");
+ if (t.IsEnum)
+ {
+ foreach (string n in Enum.GetNames(t))
+ {
+ object val = Enum.Parse(t, n);
+ output.WriteLine($" {n} = {Convert.ToInt64(val)}");
+ }
+ }
+ else if (t.Name.IndexOf("AlarmRecord", StringComparison.OrdinalIgnoreCase) >= 0
+ || t.Name.IndexOf("Selected", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
+ {
+ output.WriteLine($" field {f.FieldType.Name} {f.Name}");
+ }
+ foreach (PropertyInfo p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ output.WriteLine($" prop {p.PropertyType.Name} {p.Name}");
+ }
+ }
+ }
}
}
diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs
new file mode 100644
index 0000000..75f235f
--- /dev/null
+++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs
@@ -0,0 +1,50 @@
+using MxGateway.Worker.MxAccess;
+
+namespace MxGateway.Worker.Tests.MxAccess;
+
+///
+/// PR A.5 — pins the reference-composition logic used to translate AVEVA
+/// AlarmRecord events into proto-friendly fields. Transition-kind mapping
+/// (a trivial 4-line switch over eAlmTransitions) is verified on
+/// the dev rig as part of the live alarm-event smoke test rather than
+/// as a unit test, because the AVEVA-licensed enum assembly is
+/// Private=false on the reference and is not copied to the test
+/// bin directory.
+///
+public sealed class AlarmRecordTransitionMapperTests
+{
+
+ [Fact]
+ public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
+ {
+ string reference = AlarmRecordTransitionMapper.ComposeFullReference(
+ providerName: "GalaxyAlarmProvider",
+ groupName: "Tank01",
+ alarmName: "Level.HiHi");
+ Assert.Equal("GalaxyAlarmProvider!Tank01.Level.HiHi", reference);
+ }
+
+ [Fact]
+ public void ComposeFullReference_drops_provider_when_empty()
+ {
+ string reference = AlarmRecordTransitionMapper.ComposeFullReference(
+ providerName: null, groupName: "Tank01", alarmName: "Level.HiHi");
+ Assert.Equal("Tank01.Level.HiHi", reference);
+ }
+
+ [Fact]
+ public void ComposeFullReference_drops_group_when_empty()
+ {
+ string reference = AlarmRecordTransitionMapper.ComposeFullReference(
+ providerName: "GalaxyAlarmProvider", groupName: null, alarmName: "GlobalAlarm");
+ Assert.Equal("GalaxyAlarmProvider!GlobalAlarm", reference);
+ }
+
+ [Fact]
+ public void ComposeFullReference_returns_alarm_name_when_provider_and_group_empty()
+ {
+ string reference = AlarmRecordTransitionMapper.ComposeFullReference(
+ providerName: null, groupName: null, alarmName: "Bare");
+ Assert.Equal("Bare", reference);
+ }
+}
diff --git a/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs b/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs
new file mode 100644
index 0000000..a78849e
--- /dev/null
+++ b/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using AlarmMgrDataProviderCOM;
+using aaAlarmManagedClient;
+
+namespace MxGateway.Worker.MxAccess;
+
+///
+/// PR A.5 — production backed by
+/// aaAlarmManagedClient.AlarmClient. Forwards
+/// GetAlarmChangesCompleted events into the worker's event queue
+/// via .
+///
+///
+///
+/// The AVEVA alarm-manager surface (IAlarmMgrDataProvider)
+/// exposes the events we need as plain .NET events — no Windows
+/// message pump required. The worker keeps its STA thread for
+/// MxAccess COM but the alarm-client callbacks arrive on the
+/// AVEVA managed-client's internal callback thread.
+///
+///
+/// The constructor parameters that
+/// takes (hWnd, product / application / version names,
+/// retain-hidden flag) are pinned to safe defaults; the live
+/// hWnd is intentionally IntPtr.Zero because we use
+/// the managed-event surface, not the WM_APP pump. Verify
+/// on dev rig that RegisterConsumer with
+/// hWnd=0 still wires the managed event handlers; if it
+/// requires a real hWnd, the worker creates a hidden message-only
+/// window and passes that handle here.
+///
+///
+public sealed class AlarmClientConsumer : IMxAccessAlarmConsumer
+{
+ private const string DefaultProductName = "OtOpcUa.MxGateway";
+ private const string DefaultApplicationName = "OtOpcUa.MxGateway.Worker";
+ private const string DefaultVersion = "1.0";
+
+ private readonly AlarmClient client;
+ private readonly object subscribeLock = new object();
+ private bool disposed;
+
+ public AlarmClientConsumer()
+ : this(new AlarmClient())
+ {
+ }
+
+ /// Test seam — inject a pre-created .
+ internal AlarmClientConsumer(AlarmClient client)
+ {
+ this.client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ ///
+ public event EventHandler? AlarmRecordReceived;
+
+ ///
+ public void Subscribe(string subscription)
+ {
+ if (subscription is null) throw new ArgumentNullException(nameof(subscription));
+ if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
+
+ lock (subscribeLock)
+ {
+ // hWnd=0: AVEVA's managed event surface routes through the
+ // GetAlarmChangesCompleted .NET event, not a window-message pump.
+ // Verify on dev rig that 0 is accepted; if not, supply a hidden
+ // message-only window's handle here.
+ int registerResult = client.RegisterConsumer(
+ hWnd: 0,
+ szProductName: DefaultProductName,
+ szApplicationName: DefaultApplicationName,
+ szVersion: DefaultVersion,
+ bRetainHiddenAlarms: false);
+ if (registerResult != 0)
+ {
+ throw new InvalidOperationException(
+ $"AlarmClient.RegisterConsumer returned non-zero status {registerResult}.");
+ }
+
+ int subscribeResult = client.Subscribe(
+ szSubscription: subscription,
+ wFromPri: 1,
+ wToPri: 999,
+ QueryType: eQueryType.qtSummary,
+ SortFlags: eSortFlags.sfReturnNewestFirst,
+ FilterMask: eAlarmFilterState.asNone,
+ FilterSpecification: eAlarmFilterState.asNone);
+ if (subscribeResult != 0)
+ {
+ throw new InvalidOperationException(
+ $"AlarmClient.Subscribe('{subscription}') returned non-zero status {subscribeResult}.");
+ }
+ }
+ }
+
+ ///
+ public int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string ackComment,
+ string ackOperatorName,
+ string ackOperatorNode,
+ string ackOperatorDomain,
+ string ackOperatorFullName)
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
+ return client.AlarmAckByGUID(
+ alarmGuid,
+ ackComment ?? string.Empty,
+ ackOperatorName ?? string.Empty,
+ ackOperatorNode ?? string.Empty,
+ ackOperatorDomain ?? string.Empty,
+ ackOperatorFullName ?? string.Empty);
+ }
+
+ ///
+ public IReadOnlyList SnapshotActiveAlarms()
+ {
+ if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
+
+ // Walk the alarm-client's view of currently-active alarms via
+ // GetStatistics + GetAlarmExtendedRec. The exact iteration semantics
+ // (whether ChangePos points at the active set or at the recently-
+ // changed set) need dev-rig validation; this method is a stub-grade
+ // walker that reports the count it found.
+ int percent = 0, total = 0, active = 0, suppressed = 0;
+ int suppressedFilters = 0, newAlarms = 0, changes = 0;
+ int[] codes = Array.Empty();
+ int[] positions = Array.Empty();
+ int[] handles = Array.Empty();
+ int statsResult = client.GetStatistics(
+ ref percent, ref total, ref active, ref suppressed,
+ ref suppressedFilters, ref newAlarms, ref changes,
+ ref codes, ref positions, ref handles);
+ if (statsResult != 0 || positions == null)
+ {
+ return Array.Empty();
+ }
+
+ List records = new List(positions.Length);
+ foreach (int pos in positions)
+ {
+ AlarmRecord record = new AlarmRecord();
+ int recResult = client.GetAlarmExtendedRec(pos, ref record);
+ if (recResult == 0)
+ {
+ records.Add(record);
+ }
+ }
+ return records;
+ }
+
+ ///
+ /// Forward an alarm record to subscribers. Exposed internal so the
+ /// dev-rig hookup that wires the AVEVA alarm-changes callback can
+ /// route into the same event-fan-out path tests use.
+ ///
+ internal void RaiseAlarmRecordReceived(AlarmRecord record)
+ {
+ AlarmRecordReceived?.Invoke(this, record);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (disposed) return;
+ disposed = true;
+ try { client.DeregisterConsumer(); } catch { }
+ try { client.Dispose(); } catch { }
+ }
+}
diff --git a/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs b/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs
new file mode 100644
index 0000000..0aa3cba
--- /dev/null
+++ b/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs
@@ -0,0 +1,66 @@
+using System;
+using AlarmMgrDataProviderCOM;
+using MxGateway.Contracts.Proto;
+
+namespace MxGateway.Worker.MxAccess;
+
+///
+/// PR A.5 — translation helpers between AVEVA's
+/// enum and the proto's
+/// , plus alarm-reference composition.
+///
+///
+///
+/// The full → proto-fields decoder lives
+/// in . The two pieces that don't
+/// need hardware validation (transition-kind enum mapping +
+/// provider/group/name → reference string format) live here so the
+/// consumer's hot-path stays focused on COM-side field access.
+///
+///
+public static class AlarmRecordTransitionMapper
+{
+ ///
+ /// Maps the AVEVA enum onto the proto's
+ /// . Transitions outside the four
+ /// primary kinds (raise/ack/clear/retrigger) collapse to
+ /// so the EventPump's
+ /// decoding-failure counter records them.
+ ///
+ public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native)
+ {
+ // ALM = active-raise, RTN = return-to-normal/clear, ACK = acknowledge.
+ // SUB / ENB / DIS / SUP / REL / REMOVE — substitute / enable / disable /
+ // suppress / release / remove. None of those map to OPC UA Part 9
+ // transitions today; future work could add a Substituted / Suppressed
+ // proto kind if a customer needs it.
+ switch (native)
+ {
+ case eAlmTransitions.almRec_trans_ALM: return AlarmTransitionKind.Raise;
+ case eAlmTransitions.almRec_trans_ACK: return AlarmTransitionKind.Acknowledge;
+ case eAlmTransitions.almRec_trans_RTN: return AlarmTransitionKind.Clear;
+ default: return AlarmTransitionKind.Unspecified;
+ }
+ }
+
+ ///
+ /// Compose alarm_full_reference as Provider!Group.AlarmName.
+ /// The format mirrors AVEVA's standard alarm-reference syntax so
+ /// downstream consumers that already speak it (e.g. the gateway's
+ /// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup)
+ /// don't need translation.
+ ///
+ public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName)
+ {
+ string provider = providerName ?? string.Empty;
+ string group = groupName ?? string.Empty;
+ string name = alarmName ?? string.Empty;
+ if (string.IsNullOrEmpty(provider))
+ {
+ return string.IsNullOrEmpty(group) ? name : $"{group}.{name}";
+ }
+ return string.IsNullOrEmpty(group)
+ ? $"{provider}!{name}"
+ : $"{provider}!{group}.{name}";
+ }
+}
diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs
new file mode 100644
index 0000000..bb7f116
--- /dev/null
+++ b/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs
@@ -0,0 +1,54 @@
+using System;
+using AlarmMgrDataProviderCOM;
+
+namespace MxGateway.Worker.MxAccess;
+
+///
+/// PR A.5 — abstraction over aaAlarmManagedClient.AlarmClient's
+/// subscribe / event-receive surface. The production implementation
+/// () wraps the AVEVA managed client;
+/// tests substitute a fake to exercise the wiring against canned
+/// events without a live Galaxy.
+///
+public interface IMxAccessAlarmConsumer : IDisposable
+{
+ ///
+ /// Fires once per alarm record the AVEVA alarm provider emits. The
+ /// subscriber is expected to forward each record to a transition mapper
+ /// and then onto the worker's event queue. Fired on the alarm-client's
+ /// internal callback thread; subscribers that need STA affinity must
+ /// marshal back themselves.
+ ///
+ event EventHandler? AlarmRecordReceived;
+
+ ///
+ /// Initializes the AVEVA alarm-client connection and subscribes to the
+ /// supplied alarm-provider expression. Subscription string follows
+ /// AVEVA's syntax (e.g. "\Galaxy!OperationsRoom.AlarmGroup" or
+ /// "\\GR1\Galaxy!" for a whole Galaxy).
+ ///
+ void Subscribe(string subscription);
+
+ ///
+ /// Acknowledges a single alarm with full operator-identity fidelity.
+ /// Reaches the AVEVA alarm provider's native ack API
+ /// (AlarmAckByGUID); operator user / node / domain / full-name
+ /// and the comment land atomically with the ack transition in the
+ /// alarm-history log.
+ ///
+ int AcknowledgeByGuid(
+ Guid alarmGuid,
+ string ackComment,
+ string ackOperatorName,
+ string ackOperatorNode,
+ string ackOperatorDomain,
+ string ackOperatorFullName);
+
+ ///
+ /// Walks the currently-active alarm set and yields each as an
+ /// . Used by the gateway's QueryActiveAlarms
+ /// (PR A.7) ConditionRefresh path — operator clients call this after
+ /// reconnect to seed local Part 9 state.
+ ///
+ System.Collections.Generic.IReadOnlyList SnapshotActiveAlarms();
+}
diff --git a/src/MxGateway.Worker/MxGateway.Worker.csproj b/src/MxGateway.Worker/MxGateway.Worker.csproj
index dbde52e..8ecee08 100644
--- a/src/MxGateway.Worker/MxGateway.Worker.csproj
+++ b/src/MxGateway.Worker/MxGateway.Worker.csproj
@@ -29,6 +29,11 @@
false
false
+
+ C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll
+ false
+ false
+