From 1ac5bcafb27017193f6e9dfa1f914b4c50eb6131 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 22:42:22 -0400 Subject: [PATCH] worker: AlarmClientConsumer + transition mapper (PR A.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the worker-side consumer for AVEVA alarm transitions over the aaAlarmManagedClient API discovered in the prior foundation PR. - IAlarmMgrDataProvider.dll referenced — exposes AlarmRecord + eAlmTransitions / eQueryType / eSortFlags / eAlarmFilterState. Both DLLs (aaAlarmManagedClient + IAlarmMgrDataProvider) load in the worker's existing net48 x86 process; no new bitness boundary. - IMxAccessAlarmConsumer abstraction — Subscribe / AcknowledgeByGuid / SnapshotActiveAlarms / AlarmRecordReceived event. Test seam. - AlarmClientConsumer production wrapper — RegisterConsumer + Subscribe + AlarmAckByGUID + GetStatistics-based active-alarm walk, all delegated to AlarmClient. Uses AVEVA's managed event surface (GetAlarmChangesCompleted on IAlarmMgrDataProvider) so no Windows message pump is required — plain .NET events arrive on the alarm-client's internal callback thread. - AlarmRecordTransitionMapper — pure-function helpers: MapTransitionKind(eAlmTransitions): ALM→Raise, ACK→Acknowledge, RTN→Clear, others (SUB/ENB/DIS/SUP/REL/REMOVE)→Unspecified so EventPump's decoding-failure counter records them. ComposeFullReference(provider, group, name): Provider!Group.Name format matching AVEVA's standard alarm-reference syntax. Pinned during dev-rig validation (subsequent commits): 1. Confirm RegisterConsumer accepts hWnd=0 — if it requires a real hwnd, the worker creates a hidden message-only window and passes that handle. The managed event surface should make this irrelevant but the AVEVA API is older than its managed wrapper. 2. Wire AlarmClientConsumer.AlarmRecordReceived: the AVEVA IAlarmMgrDataProvider.GetAlarmChangesCompleted event needs to be hooked from inside the AlarmClient — find the proper accessor (likely a property exposing the inner provider). 3. AlarmRecord field-by-field translation into the proto event uses MxAccessAlarmEventSink.EnqueueTransition (existing plumbing). The AlarmRecord field names (ar_OrigTime, AlarmName, AckOperatorFullName, AckComment, etc.) are pinned in the discovery dump preserved in AlarmClientDiscoveryTests. Tests: 127 pass (4 new ComposeFullReference cases + 1 Skip-gated discovery probe). Transition-kind enum mapping is dev-rig-validated rather than unit-tested because the AVEVA assembly is Private=false on the reference and isn't copied to the test bin directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../AlarmClientDiscoveryTests.cs | 35 ++++ .../AlarmRecordTransitionMapperTests.cs | 50 +++++ .../MxAccess/AlarmClientConsumer.cs | 172 ++++++++++++++++++ .../MxAccess/AlarmRecordTransitionMapper.cs | 66 +++++++ .../MxAccess/IMxAccessAlarmConsumer.cs | 54 ++++++ src/MxGateway.Worker/MxGateway.Worker.csproj | 5 + 6 files changed, 382 insertions(+) create mode 100644 src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs create mode 100644 src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs create mode 100644 src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs create mode 100644 src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs 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 + -- 2.52.0