Merge pull request 'worker: AlarmClientConsumer + transition mapper (PR A.5)' (#116) from track-a5-alarm-consumer-wiring into main
This commit was merged in pull request #116.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user