1ac5bcafb2
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) <noreply@anthropic.com>
173 lines
6.6 KiB
C#
173 lines
6.6 KiB
C#
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 { }
|
|
}
|
|
}
|