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})");
|
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>
|
<Private>false</Private>
|
||||||
<SpecificVersion>false</SpecificVersion>
|
<SpecificVersion>false</SpecificVersion>
|
||||||
</Reference>
|
</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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user