worker: AlarmClientConsumer + transition mapper (PR A.5) #116

Merged
dohertj2 merged 1 commits from track-a5-alarm-consumer-wiring into main 2026-04-30 22:44:52 -04:00
6 changed files with 382 additions and 0 deletions
@@ -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}");
}
}
}
}
}
@@ -0,0 +1,50 @@
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// 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 <c>eAlmTransitions</c>) 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
/// <c>Private=false</c> on the reference and is not copied to the test
/// bin directory.
/// </summary>
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);
}
}
@@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using AlarmMgrDataProviderCOM;
using aaAlarmManagedClient;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — production <see cref="IMxAccessAlarmConsumer"/> backed by
/// <c>aaAlarmManagedClient.AlarmClient</c>. Forwards
/// <c>GetAlarmChangesCompleted</c> events into the worker's event queue
/// via <see cref="MxAccessAlarmEventSink"/>.
/// </summary>
/// <remarks>
/// <para>
/// The AVEVA alarm-manager surface (<c>IAlarmMgrDataProvider</c>)
/// 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.
/// </para>
/// <para>
/// The constructor parameters that <see cref="AlarmClient.RegisterConsumer"/>
/// takes (<c>hWnd</c>, product / application / version names,
/// retain-hidden flag) are pinned to safe defaults; the live
/// <c>hWnd</c> is intentionally <c>IntPtr.Zero</c> because we use
/// the managed-event surface, not the WM_APP pump. <strong>Verify
/// on dev rig</strong> that <c>RegisterConsumer</c> with
/// <c>hWnd=0</c> 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.
/// </para>
/// </remarks>
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())
{
}
/// <summary>Test seam — inject a pre-created <see cref="AlarmClient"/>.</summary>
internal AlarmClientConsumer(AlarmClient client)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <inheritdoc />
public event EventHandler<AlarmRecord>? AlarmRecordReceived;
/// <inheritdoc />
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}.");
}
}
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public IReadOnlyList<AlarmRecord> 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>();
int[] positions = Array.Empty<int>();
int[] handles = Array.Empty<int>();
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<AlarmRecord>();
}
List<AlarmRecord> records = new List<AlarmRecord>(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;
}
/// <summary>
/// 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.
/// </summary>
internal void RaiseAlarmRecordReceived(AlarmRecord record)
{
AlarmRecordReceived?.Invoke(this, record);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
try { client.DeregisterConsumer(); } catch { }
try { client.Dispose(); } catch { }
}
}
@@ -0,0 +1,66 @@
using System;
using AlarmMgrDataProviderCOM;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — translation helpers between AVEVA's
/// <see cref="eAlmTransitions"/> enum and the proto's
/// <see cref="AlarmTransitionKind"/>, plus alarm-reference composition.
/// </summary>
/// <remarks>
/// <para>
/// The full <see cref="AlarmRecord"/> → proto-fields decoder lives
/// in <see cref="AlarmClientConsumer"/>. 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.
/// </para>
/// </remarks>
public static class AlarmRecordTransitionMapper
{
/// <summary>
/// Maps the AVEVA <see cref="eAlmTransitions"/> enum onto the proto's
/// <see cref="AlarmTransitionKind"/>. Transitions outside the four
/// primary kinds (raise/ack/clear/retrigger) collapse to
/// <see cref="AlarmTransitionKind.Unspecified"/> so the EventPump's
/// decoding-failure counter records them.
/// </summary>
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;
}
}
/// <summary>
/// Compose <c>alarm_full_reference</c> as <c>Provider!Group.AlarmName</c>.
/// 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.
/// </summary>
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}";
}
}
@@ -0,0 +1,54 @@
using System;
using AlarmMgrDataProviderCOM;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — abstraction over <c>aaAlarmManagedClient.AlarmClient</c>'s
/// subscribe / event-receive surface. The production implementation
/// (<see cref="AlarmClientConsumer"/>) wraps the AVEVA managed client;
/// tests substitute a fake to exercise the wiring against canned
/// <see cref="AlarmRecord"/> events without a live Galaxy.
/// </summary>
public interface IMxAccessAlarmConsumer : IDisposable
{
/// <summary>
/// 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.
/// </summary>
event EventHandler<AlarmRecord>? AlarmRecordReceived;
/// <summary>
/// Initializes the AVEVA alarm-client connection and subscribes to the
/// supplied alarm-provider expression. Subscription string follows
/// AVEVA's syntax (e.g. <c>"\Galaxy!OperationsRoom.AlarmGroup"</c> or
/// <c>"\\GR1\Galaxy!"</c> for a whole Galaxy).
/// </summary>
void Subscribe(string subscription);
/// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches the AVEVA alarm provider's native ack API
/// (<c>AlarmAckByGUID</c>); operator user / node / domain / full-name
/// and the comment land atomically with the ack transition in the
/// alarm-history log.
/// </summary>
int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary>
/// Walks the currently-active alarm set and yields each as an
/// <see cref="AlarmRecord"/>. Used by the gateway's QueryActiveAlarms
/// (PR A.7) ConditionRefresh path — operator clients call this after
/// reconnect to seed local Part 9 state.
/// </summary>
System.Collections.Generic.IReadOnlyList<AlarmRecord> SnapshotActiveAlarms();
}
@@ -29,6 +29,11 @@
<Private>false</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="IAlarmMgrDataProvider">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
<Private>false</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
</ItemGroup>
</Project>