A.2: replace AlarmClientConsumer with wnwrap-based polling consumer
Switch the worker's alarm-consumer surface from `aaAlarmManagedClient.AlarmClient` to `WNWRAPCONSUMERLib.wwAlarmConsumerClass` (CLSID 7AB52E5F-…) hosted by `wnwrapConsumer.dll`. The new path returns alarm records as a BSTR XML payload via `GetXmlCurrentAlarms2`, bypassing the FILETIME→DateTime auto-marshaling that crashed `GetHighPriAlarm` with ArgumentOutOfRangeException on every poll. Live captured 60/60 polls clean against `\DESKTOP-6JL3KKO\Galaxy!DEV` while a System Platform script flipped TestMachine_001.TestAlarm001 every 10s; the GUID, priority, state (UNACK_ALM ↔ UNACK_RTN), and ASCII-formatted timestamps arrived end-to-end. Implementation: - `Interop.WNWRAPCONSUMERLib.dll` generated via tlbimp, checked in under `lib/` so dev boxes don't need the SDK to build. - New `WnWrapAlarmConsumer` (replaces `AlarmClientConsumer`): owns a 500ms polling timer, parses `GetXmlCurrentAlarms2` output, diffs the snapshot keyed by alarm GUID, and raises one `MxAlarmTransitionEvent` per state change. Includes the Initialize→Register-before-Subscribe ordering fix found during Discovery probe runs. - New library-agnostic types `MxAlarmSnapshotRecord` / `MxAlarmStateKind` / `MxAlarmTransitionEvent` so the proto-build path is testable without an AVEVA install. - `AlarmRecordTransitionMapper` retired the COM-coupled `MapTransitionKind(eAlmTransitions)`; new pure helpers `ParseStateKind`, `MapTransition(prev, curr)`, and `ParseTransitionTimestampUtc` cover XML decode + state-delta logic. - `IMxAccessAlarmConsumer` event surface changed from `EventHandler<AlarmRecord>` to `EventHandler<MxAlarmTransitionEvent>` and `SnapshotActiveAlarms()` returns `MxAlarmSnapshotRecord` — decoupling the interface from any specific COM library. - Worker csproj drops `aaAlarmManagedClient` / `IAlarmMgrDataProvider` refs; adds `Interop.WNWRAPCONSUMERLib`. Tests: - 36 new unit tests (state-string mapping, prev/current → proto kind decision table, timestamp UTC reassembly, XML payload parser, 32-char hex GUID round-trip) covering everything that doesn't touch the live COM surface — all passing. - Skip-gated `WnWrapConsumerProbeTests.ProbeWnWrapConsumer` archives the live capture flow for regression / future probes. Docs: - `docs/AlarmClientDiscovery.md` "Option A — captured" section records sample XML payloads, the mangled `SetXmlAlarmQuery` round-trip (prefer `Subscribe` for filtering), the `GetStatistics` AccessViolationException quirk, and the worker-integration outline. Pre-existing failure noted (separate): `MxAccessInteropReference_ExistsOnlyInWorkerProject` was already failing on HEAD — the test project still references `ArchestrA.MxAccess` for the Skip-gated discovery probes. Not regressed by this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
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>
|
||||
/// <strong>⚠ Architecture finding (2026-05-01 reflection probe —
|
||||
/// see <c>docs/AlarmClientDiscovery.md</c>):</strong> contrary to the
|
||||
/// original PR A.5 design, <c>aaAlarmManagedClient.AlarmClient</c>
|
||||
/// exposes <em>zero</em> public events on the deployed assembly
|
||||
/// (<c>aaAlarmManagedClient.dll</c> v1.0.7368.41290). There is no
|
||||
/// managed event surface. <c>RegisterConsumer(hWnd, …)</c> takes a
|
||||
/// window handle because the actual notification mechanism is
|
||||
/// WM_APP-pump messaging — AVEVA's alarm provider WM_APP-pokes the
|
||||
/// registered window, and the consumer pulls the change set via
|
||||
/// <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each poke.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// As a result, <see cref="AlarmRecordReceived"/> has no production
|
||||
/// caller — <see cref="RaiseAlarmRecordReceived"/> is invoked only
|
||||
/// from tests. <see cref="Subscribe"/> currently calls
|
||||
/// <c>RegisterConsumer(hWnd: 0, …)</c> + <c>Subscribe(…)</c> and
|
||||
/// returns OK, but no notifications will arrive at runtime because
|
||||
/// no window is attached. Until A.2's WM_APP pump lands, the
|
||||
/// gateway's <c>MX_EVENT_FAMILY_ON_ALARM_TRANSITION</c> family
|
||||
/// cannot carry any events.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Subscribe"/> as written is still a load-bearing call
|
||||
/// the WM_APP path needs (it tells the alarm provider which
|
||||
/// subscription expression to scope notifications to). The wiring
|
||||
/// that has to change is the notification-receive side: replace the
|
||||
/// <c>hWnd: 0</c> default with a real hidden message-only window
|
||||
/// hWnd owned by the worker's STA, and add a <c>WindowProc</c> that
|
||||
/// routes the AVEVA WM_APP message into a change-pull path that
|
||||
/// ultimately invokes <see cref="RaiseAlarmRecordReceived"/>.
|
||||
/// <see cref="AcknowledgeByGuid"/> and <see cref="SnapshotActiveAlarms"/>
|
||||
/// are pull-style and don't depend on the event surface — they're
|
||||
/// correct as is.
|
||||
/// </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: placeholder — the AVEVA alarm provider notifies via
|
||||
// WM_APP messages, not via a managed-client event surface (see
|
||||
// docs/AlarmClientDiscovery.md, 2026-05-01 reflection probe).
|
||||
// RegisterConsumer accepts hWnd=0 but no notifications will reach
|
||||
// this consumer until the WM_APP pump lands as part of A.2 and
|
||||
// a real message-only window's handle is supplied 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 { }
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,77 @@
|
||||
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.
|
||||
/// Translation helpers between the wnwrapConsumer XML payload and the
|
||||
/// proto-friendly <see cref="AlarmTransitionKind"/> wire format, 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.
|
||||
/// These mappings stay pure and library-agnostic so they're unit
|
||||
/// testable without an AVEVA install. The COM-side I/O lives on
|
||||
/// <see cref="WnWrapAlarmConsumer"/>.
|
||||
/// </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.
|
||||
/// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
|
||||
/// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
|
||||
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
|
||||
/// <see cref="MxAlarmStateKind.Unspecified"/>.
|
||||
/// </summary>
|
||||
public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native)
|
||||
public static MxAlarmStateKind ParseStateKind(string? stateXml)
|
||||
{
|
||||
// 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)
|
||||
if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
|
||||
return stateXml!.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
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;
|
||||
"UNACK_ALM" => MxAlarmStateKind.UnackAlm,
|
||||
"ACK_ALM" => MxAlarmStateKind.AckAlm,
|
||||
"UNACK_RTN" => MxAlarmStateKind.UnackRtn,
|
||||
"ACK_RTN" => MxAlarmStateKind.AckRtn,
|
||||
_ => MxAlarmStateKind.Unspecified,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decide which proto transition kind a state change represents.
|
||||
/// The decision table:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>previous=Unspecified</c> + <c>current=*Alm</c> → Raise (new alarm).</description></item>
|
||||
/// <item><description><c>previous=Unspecified</c> + <c>current=*Rtn</c> → Clear (alarm appeared in cleared state — rare; missed the raise).</description></item>
|
||||
/// <item><description><c>previous=Unack*</c> + <c>current=Ack*</c> → Acknowledge.</description></item>
|
||||
/// <item><description><c>previous=*Alm</c> + <c>current=*Rtn</c> → Clear.</description></item>
|
||||
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
|
||||
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static AlarmTransitionKind MapTransition(
|
||||
MxAlarmStateKind previous,
|
||||
MxAlarmStateKind current)
|
||||
{
|
||||
if (current == MxAlarmStateKind.Unspecified) return AlarmTransitionKind.Unspecified;
|
||||
|
||||
bool currentIsAlm = current is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||
bool currentIsRtn = current is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||
bool currentIsAcked = current is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||
|
||||
if (previous == MxAlarmStateKind.Unspecified)
|
||||
{
|
||||
return currentIsAlm ? AlarmTransitionKind.Raise : AlarmTransitionKind.Clear;
|
||||
}
|
||||
|
||||
bool previousIsAlm = previous is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||
bool previousIsRtn = previous is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||
bool previousIsAcked = previous is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||
|
||||
if (previousIsAlm && currentIsRtn) return AlarmTransitionKind.Clear;
|
||||
if (previousIsRtn && currentIsAlm) return AlarmTransitionKind.Raise;
|
||||
if (!previousIsAcked && currentIsAcked) return AlarmTransitionKind.Acknowledge;
|
||||
|
||||
return AlarmTransitionKind.Unspecified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -63,4 +94,90 @@ public static class AlarmRecordTransitionMapper
|
||||
? $"{provider}!{name}"
|
||||
: $"{provider}!{group}.{name}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassemble a UTC <see cref="DateTime"/> from the wnwrap XML's
|
||||
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
||||
/// fields. Returns <see cref="DateTime.MinValue"/> when DATE / TIME
|
||||
/// can't be parsed (best-effort — failure is non-fatal; the proto
|
||||
/// will carry the epoch and the EventQueue's fault counter records
|
||||
/// the parse miss).
|
||||
/// </summary>
|
||||
/// <param name="xmlDate">e.g. <c>"2026/5/1"</c> (no zero-padding).</param>
|
||||
/// <param name="xmlTime">e.g. <c>"13:26:14.709"</c>.</param>
|
||||
/// <param name="gmtOffsetMinutes">Offset of the producer's local time vs UTC, in minutes.</param>
|
||||
/// <param name="dstAdjustMinutes">DST adjustment already applied to local time, in minutes.</param>
|
||||
public static DateTime ParseTransitionTimestampUtc(
|
||||
string? xmlDate,
|
||||
string? xmlTime,
|
||||
int gmtOffsetMinutes,
|
||||
int dstAdjustMinutes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xmlDate) || string.IsNullOrWhiteSpace(xmlTime))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
// Parse DATE: yyyy/M/d (no zero padding observed). Use ParseExact with
|
||||
// multiple format candidates — AVEVA's locale may format differently
|
||||
// on non-en-US hosts.
|
||||
string[] dateFormats =
|
||||
{
|
||||
"yyyy/M/d", "yyyy/MM/dd", "M/d/yyyy", "MM/dd/yyyy",
|
||||
"d/M/yyyy", "dd/MM/yyyy",
|
||||
};
|
||||
string dateTrim = xmlDate!.Trim();
|
||||
if (!DateTime.TryParseExact(
|
||||
dateTrim,
|
||||
dateFormats,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out DateTime date))
|
||||
{
|
||||
if (!DateTime.TryParse(
|
||||
dateTrim,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out date))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TIME: H:m:s.fff (variable precision).
|
||||
string[] timeFormats =
|
||||
{
|
||||
"H:m:s.fff", "H:m:s.ff", "H:m:s.f", "H:m:s",
|
||||
"HH:mm:ss.fff", "HH:mm:ss.ff", "HH:mm:ss.f", "HH:mm:ss",
|
||||
};
|
||||
string timeTrim = xmlTime!.Trim();
|
||||
if (!DateTime.TryParseExact(
|
||||
timeTrim,
|
||||
timeFormats,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out DateTime time))
|
||||
{
|
||||
if (!DateTime.TryParse(
|
||||
timeTrim,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out time))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime localProducerTime = new DateTime(
|
||||
date.Year, date.Month, date.Day,
|
||||
time.Hour, time.Minute, time.Second, time.Millisecond,
|
||||
DateTimeKind.Unspecified);
|
||||
|
||||
// GMTOFFSET = minutes east of UTC (or behind, depending on convention).
|
||||
// The wnwrap convention observed: GMTOFFSET=240, DSTADJUST=0 for
|
||||
// EDT (UTC-4) — so the field is "minutes from local to UTC". To get
|
||||
// UTC, ADD the offset.
|
||||
DateTime utc = localProducerTime.AddMinutes(gmtOffsetMinutes - dstAdjustMinutes);
|
||||
return DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
using System;
|
||||
using AlarmMgrDataProviderCOM;
|
||||
using System.Collections.Generic;
|
||||
|
||||
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.
|
||||
/// Abstraction over an AVEVA alarm-consumer COM library. The production
|
||||
/// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
|
||||
/// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
|
||||
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
|
||||
/// tests substitute a fake to drive transition events without a live
|
||||
/// Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The receive surface is poll-based: the production consumer
|
||||
/// periodically calls <c>GetXmlCurrentAlarms2</c>, parses the
|
||||
/// returned XML payload, diffs against the previous snapshot keyed
|
||||
/// by alarm GUID, and raises <see cref="AlarmTransitionEmitted"/>
|
||||
/// once per state change. This bypasses the FILETIME marshaling
|
||||
/// crash in <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>
|
||||
/// (see <c>docs/AlarmClientDiscovery.md</c>) — XML strings carry
|
||||
/// timestamps as ASCII fields, no DateTime auto-conversion happens
|
||||
/// on the .NET interop boundary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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.
|
||||
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
||||
/// clear, or new-alarm-already-acked-on-arrival). Subscribers are
|
||||
/// expected to translate the record into the proto family
|
||||
/// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
|
||||
/// polling thread (the worker's STA in production); subscribers that
|
||||
/// need a different thread must marshal back themselves.
|
||||
/// </summary>
|
||||
event EventHandler<AlarmRecord>? AlarmRecordReceived;
|
||||
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||
|
||||
/// <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).
|
||||
/// Initializes the AVEVA alarm-client connection, registers as a
|
||||
/// consumer, and subscribes to the supplied alarm-provider expression.
|
||||
/// Subscription string follows AVEVA's canonical format:
|
||||
/// <c>\\<node>\Galaxy!<area></c>. The literal "Galaxy" is
|
||||
/// the provider name (regardless of the configured Galaxy database
|
||||
/// name). Calling Subscribe also begins polling on the consumer's
|
||||
/// internal timer.
|
||||
/// </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.
|
||||
/// Reaches AVEVA's native <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,
|
||||
@@ -45,10 +62,10 @@ public interface IMxAccessAlarmConsumer : IDisposable
|
||||
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.
|
||||
/// Returns the consumer's most recently parsed snapshot of currently
|
||||
/// active alarms. 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();
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
|
||||
}
|
||||
|
||||
@@ -4,70 +4,21 @@ using MxGateway.Contracts.Proto;
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the
|
||||
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's
|
||||
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages
|
||||
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
|
||||
/// Sink for native MxAccess alarm transitions. Bridges
|
||||
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
|
||||
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
|
||||
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Architecture (revised 2026-05-01 — see
|
||||
/// <c>docs/AlarmClientDiscovery.md</c>):</strong> the worker hosts
|
||||
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing
|
||||
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET
|
||||
/// Framework 4.8. The MxAccess COM Toolkit at
|
||||
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
|
||||
/// exposes no alarm events; the alarm provider lives in a separate
|
||||
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Notification mechanism: WM_APP pump.</strong> A reflection
|
||||
/// probe of <c>aaAlarmManagedClient.dll</c> (v1.0.7368.41290) on
|
||||
/// 2026-05-01 confirmed the public <c>AlarmClient</c> class has zero
|
||||
/// public events. The original PR A.5 design (managed-event surface,
|
||||
/// no message pump) is incorrect against this assembly. AVEVA's
|
||||
/// alarm provider WM_APP-pokes a window registered through
|
||||
/// <c>RegisterConsumer(hWnd, …)</c>; the consumer pulls the change
|
||||
/// set via <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each
|
||||
/// poke. PR A.5's <see cref="AlarmClientConsumer"/> still owns the
|
||||
/// <see cref="AlarmClient"/> handle and the
|
||||
/// <see cref="AlarmClient.Subscribe"/> /
|
||||
/// <see cref="AlarmClient.AlarmAckByGUID"/> pull-style calls; only
|
||||
/// the receive path is wrong.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Discovered API surface</strong> (see
|
||||
/// <c>AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface</c> in
|
||||
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe; full
|
||||
/// output captured in <c>docs/AlarmClientDiscovery.md</c>):
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>RegisterConsumer(hWnd, productName, applicationName, version, retainHidden)</c> — registers a Windows-message-pump consumer; the AVEVA alarm service WM_APP-pokes the hWnd when alarms change.</description></item>
|
||||
/// <item><description><c>Subscribe(provider, fromPri, toPri, queryType, sortFlags, filterMask, filterSpec)</c> — subscribes to a Galaxy alarm provider with priority + filter scoping.</description></item>
|
||||
/// <item><description><c>GetStatistics(out percentQuery, totalAlarms, activeAlarms, …, out int[] changeCodes, out int[] changePos, out int[] hAlarm)</c> — called on each WM_APP poke; enumerates which alarms changed.</description></item>
|
||||
/// <item><description><c>GetAlarmExtendedRec(index, out AlarmRecord)</c> — pulls the full alarm record (operator, comment, original raise, category, severity).</description></item>
|
||||
/// <item><description><c>AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode, oprDomain, oprFullName)</c> — full-fidelity native Acknowledge: comment + four operator-identity fields are atomic with the ack transition.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <strong>Open questions before A.2 implementation</strong>
|
||||
/// (see <c>docs/AlarmClientDiscovery.md</c> "Implications for A.2"):
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item><description>WM_APP message ID — not in the public surface, needs AVEVA C++ Toolkit reference or a runtime probe.</description></item>
|
||||
/// <item><description><c>wParam</c> / <c>lParam</c> semantics — likely none (the pattern is "got poked → pull state via <c>GetStatistics</c>"), but confirm during the probe.</description></item>
|
||||
/// <item><description>STA / threading affinity for the message-only window — likely the worker's existing STA, but if AVEVA assumes UI-thread inside <c>GetStatistics</c> the alarm path may need its own STA.</description></item>
|
||||
/// <item><description>Subscription scope — reuse the configured Galaxy name from the data session.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Until A.2 lands a hidden message-only window + WindowProc that
|
||||
/// routes WM_APP into <see cref="EnqueueTransition"/>,
|
||||
/// <see cref="Attach"/> is a no-op. The worker continues to function
|
||||
/// for data subscriptions, and the gateway's
|
||||
/// <see cref="MxEventFamily.OnAlarmTransition"/> family is reserved
|
||||
/// on the wire but never emitted. lmxopcua-side
|
||||
/// <c>AlarmConditionService</c> keeps the sub-attribute synthesis
|
||||
/// active and continues to surface alarms to OPC UA Part 9 clients
|
||||
/// in the meantime.
|
||||
/// The dispatcher subscribes the consumer's
|
||||
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
|
||||
/// to <see cref="EnqueueTransition"/> at session attach time. The
|
||||
/// <see cref="Attach"/> override here is a stub kept for the data-
|
||||
/// session shape; the actual wire-up between consumer and sink
|
||||
/// lives in the A.3 dispatcher (one step up the stack). Captured
|
||||
/// payload schema and consumer threading discipline are described in
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
|
||||
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
|
||||
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
|
||||
/// Decoupling the consumer from any specific COM library keeps the
|
||||
/// proto-build path testable without an AVEVA install.
|
||||
/// </summary>
|
||||
public enum MxAlarmStateKind
|
||||
{
|
||||
Unspecified = 0,
|
||||
UnackAlm = 1,
|
||||
AckAlm = 2,
|
||||
UnackRtn = 3,
|
||||
AckRtn = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
|
||||
/// Field names match the captured XML schema (see
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
|
||||
/// </summary>
|
||||
public sealed class MxAlarmSnapshotRecord
|
||||
{
|
||||
public Guid AlarmGuid { get; set; }
|
||||
public DateTime TransitionTimestampUtc { get; set; }
|
||||
public string ProviderNode { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string Group { get; set; } = string.Empty;
|
||||
public string TagName { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Limit { get; set; } = string.Empty;
|
||||
public int Priority { get; set; }
|
||||
public MxAlarmStateKind State { get; set; }
|
||||
public string OperatorNode { get; set; } = string.Empty;
|
||||
public string OperatorName { get; set; } = string.Empty;
|
||||
public string AlarmComment { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One transition emitted by the consumer's snapshot diff. Pairs the
|
||||
/// latest record with its previous state so the proto layer can decide
|
||||
/// whether the transition is a Raise / Acknowledge / Clear.
|
||||
/// </summary>
|
||||
public sealed class MxAlarmTransitionEvent : EventArgs
|
||||
{
|
||||
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
|
||||
|
||||
/// <summary>
|
||||
/// The state on the consumer's previous polled snapshot, or
|
||||
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
|
||||
/// first time the GUID has been observed.
|
||||
/// </summary>
|
||||
public MxAlarmStateKind PreviousState { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Xml;
|
||||
using WNWRAPCONSUMERLib;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
|
||||
/// standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
|
||||
/// (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
|
||||
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Replaces the earlier <c>AlarmClientConsumer</c> built on
|
||||
/// <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
|
||||
/// <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
|
||||
/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
|
||||
/// The wnwrap surface returns the alarm record as a BSTR XML string
|
||||
/// via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
|
||||
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
||||
/// fields and never touch the .NET DateTime marshaler. See
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
|
||||
/// the discovery and the captured payload schema.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Threading.</strong> The wnwrap CLSID is registered with
|
||||
/// <c>ThreadingModel=Apartment</c>. The consumer must be created
|
||||
/// and operated from an STA thread; the worker's
|
||||
/// <see cref="MxAccessStaSession"/> already runs an STA pump that
|
||||
/// is the natural host. Polling cadence is governed by
|
||||
/// <see cref="PollIntervalMilliseconds"/> on a dedicated timer the
|
||||
/// consumer owns; in production the worker's STA dispatcher should
|
||||
/// marshal each callback onto the STA thread before invoking
|
||||
/// <c>GetXmlCurrentAlarms2</c>. For now (test-grade), this consumer
|
||||
/// calls the COM API on whichever thread the timer fires it on —
|
||||
/// the worker bootstrap will gain a thin "run-on-STA" wrapper as
|
||||
/// part of A.3 dispatcher wiring.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
private const string DefaultProductName = "OtOpcUa.MxGateway";
|
||||
private const string DefaultApplicationName = "OtOpcUa.MxGateway.Worker";
|
||||
private const string DefaultVersion = "1.0";
|
||||
private const int DefaultPollIntervalMilliseconds = 500;
|
||||
private const int DefaultMaxAlarmsPerFetch = 1024;
|
||||
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||
private readonly int pollIntervalMs;
|
||||
private readonly int maxAlarmsPerFetch;
|
||||
|
||||
private wwAlarmConsumerClass? client;
|
||||
private Timer? pollTimer;
|
||||
private bool subscribed;
|
||||
private bool disposed;
|
||||
|
||||
public WnWrapAlarmConsumer()
|
||||
: this(new wwAlarmConsumerClass(), DefaultPollIntervalMilliseconds, DefaultMaxAlarmsPerFetch)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a pre-created COM client and tune the poll cadence.</summary>
|
||||
internal WnWrapAlarmConsumer(
|
||||
wwAlarmConsumerClass client,
|
||||
int pollIntervalMilliseconds,
|
||||
int maxAlarmsPerFetch)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.pollIntervalMs = pollIntervalMilliseconds > 0
|
||||
? pollIntervalMilliseconds
|
||||
: DefaultPollIntervalMilliseconds;
|
||||
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
||||
? maxAlarmsPerFetch
|
||||
: DefaultMaxAlarmsPerFetch;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||
|
||||
public int PollIntervalMilliseconds => pollIntervalMs;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(string subscription)
|
||||
{
|
||||
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (subscribed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WnWrapAlarmConsumer.Subscribe was called more than once; " +
|
||||
"wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
|
||||
}
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// Per AlarmClientDiscovery.md: InitializeConsumer MUST precede
|
||||
// RegisterConsumer for the alarm provider chain to become visible.
|
||||
int init = com.InitializeConsumer(DefaultApplicationName);
|
||||
if (init != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
|
||||
}
|
||||
|
||||
// hWnd=0: wnwrap supports a pull-based model — no message pump
|
||||
// is required. We poll GetXmlCurrentAlarms2 on a timer below.
|
||||
int reg = com.RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName,
|
||||
szVersion: DefaultVersion);
|
||||
if (reg != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
|
||||
}
|
||||
|
||||
int sub = com.Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
if (sub != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
|
||||
}
|
||||
|
||||
subscribed = true;
|
||||
pollTimer = new Timer(OnPoll, state: null, dueTime: 0, period: pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// VBGUID is wnwrap's GUID interop struct (same memory layout as
|
||||
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
|
||||
// unmanaged-blittable round-trip.
|
||||
VBGUID vb = ToVbGuid(alarmGuid);
|
||||
return com.AlarmAckByGUID(
|
||||
AlmGUID: vb,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty,
|
||||
szDomainName: ackOperatorDomain ?? string.Empty,
|
||||
szOprFullName: ackOperatorFullName ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
lock (syncRoot)
|
||||
{
|
||||
List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
|
||||
foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
|
||||
{
|
||||
if (record.State == MxAlarmStateKind.UnackAlm
|
||||
|| record.State == MxAlarmStateKind.AckAlm)
|
||||
{
|
||||
active.Add(record);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPoll(object? _)
|
||||
{
|
||||
if (disposed) return;
|
||||
try
|
||||
{
|
||||
PollOnce();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow — the poll loop must not propagate exceptions out of
|
||||
// the timer callback, or the worker process tears down. The
|
||||
// EventQueue fault counter (wired in by the future A.3 dispatcher)
|
||||
// is the right place to surface poll failures; for now the
|
||||
// exception is intentionally silent so the timer keeps firing.
|
||||
_ = ex;
|
||||
}
|
||||
}
|
||||
|
||||
internal void PollOnce()
|
||||
{
|
||||
wwAlarmConsumerClass? com;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed || !subscribed) return;
|
||||
com = client;
|
||||
}
|
||||
if (com is null) return;
|
||||
|
||||
object xmlObj = string.Empty;
|
||||
com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
|
||||
string xml = xmlObj?.ToString() ?? string.Empty;
|
||||
if (xml.Length == 0) return;
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
|
||||
|
||||
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
|
||||
lock (syncRoot)
|
||||
{
|
||||
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
||||
{
|
||||
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
|
||||
if (latestSnapshot.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
|
||||
{
|
||||
previousState = prev.State;
|
||||
if (previousState == kv.Value.State) continue; // no transition
|
||||
}
|
||||
transitions.Add(new MxAlarmTransitionEvent
|
||||
{
|
||||
Record = kv.Value,
|
||||
PreviousState = previousState,
|
||||
});
|
||||
}
|
||||
latestSnapshot.Clear();
|
||||
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
||||
{
|
||||
latestSnapshot[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (transitions.Count == 0) return;
|
||||
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
|
||||
if (handler is null) return;
|
||||
foreach (MxAlarmTransitionEvent transition in transitions)
|
||||
{
|
||||
handler.Invoke(this, transition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
|
||||
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
|
||||
/// silently dropped (no fault is recorded — the next poll will
|
||||
/// resync).
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
||||
{
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(xml)) return records;
|
||||
|
||||
XmlDocument doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
||||
if (alarmNodes is null) return records;
|
||||
|
||||
foreach (XmlNode alarmNode in alarmNodes)
|
||||
{
|
||||
string guidHex = TextOf(alarmNode, "GUID");
|
||||
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
||||
|
||||
string xmlDate = TextOf(alarmNode, "DATE");
|
||||
string xmlTime = TextOf(alarmNode, "TIME");
|
||||
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
|
||||
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
|
||||
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
||||
xmlDate, xmlTime, gmtOffset, dstAdjust);
|
||||
|
||||
records[guid] = new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = guid,
|
||||
TransitionTimestampUtc = tsUtc,
|
||||
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
|
||||
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
|
||||
Group = TextOf(alarmNode, "GROUP"),
|
||||
TagName = TextOf(alarmNode, "TAGNAME"),
|
||||
Type = TextOf(alarmNode, "TYPE"),
|
||||
Value = TextOf(alarmNode, "VALUE"),
|
||||
Limit = TextOf(alarmNode, "LIMIT"),
|
||||
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
|
||||
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
|
||||
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
|
||||
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
|
||||
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
|
||||
};
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static string TextOf(XmlNode parent, string childName)
|
||||
{
|
||||
XmlNode? node = parent.SelectSingleNode(childName);
|
||||
return node?.InnerText ?? string.Empty;
|
||||
}
|
||||
|
||||
private static int ParseInt(string text)
|
||||
{
|
||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
||||
? n : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
|
||||
/// dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
|
||||
/// to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
|
||||
/// </summary>
|
||||
public static bool TryParseHexGuid(string? hex, out Guid guid)
|
||||
{
|
||||
guid = Guid.Empty;
|
||||
if (string.IsNullOrWhiteSpace(hex)) return false;
|
||||
string trimmed = hex!.Trim();
|
||||
if (Guid.TryParse(trimmed, out guid)) return true;
|
||||
if (trimmed.Length != 32) return false;
|
||||
string canonical =
|
||||
trimmed.Substring(0, 8) + "-" +
|
||||
trimmed.Substring(8, 4) + "-" +
|
||||
trimmed.Substring(12, 4) + "-" +
|
||||
trimmed.Substring(16, 4) + "-" +
|
||||
trimmed.Substring(20, 12);
|
||||
return Guid.TryParse(canonical, out guid);
|
||||
}
|
||||
|
||||
private static VBGUID ToVbGuid(Guid g)
|
||||
{
|
||||
byte[] bytes = g.ToByteArray();
|
||||
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
|
||||
VBGUID vb = new VBGUID
|
||||
{
|
||||
Data1 = BitConverter.ToInt32(bytes, 0),
|
||||
Data2 = BitConverter.ToInt16(bytes, 4),
|
||||
Data3 = BitConverter.ToInt16(bytes, 6),
|
||||
Data4 = new byte[8],
|
||||
};
|
||||
Array.Copy(bytes, 8, vb.Data4, 0, 8);
|
||||
return vb;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Timer? timerToDispose;
|
||||
wwAlarmConsumerClass? clientToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
timerToDispose = pollTimer;
|
||||
pollTimer = null;
|
||||
clientToDispose = client;
|
||||
client = null;
|
||||
}
|
||||
timerToDispose?.Dispose();
|
||||
if (clientToDispose is not null)
|
||||
{
|
||||
try { clientToDispose.DeregisterConsumer(); } catch { /* swallow */ }
|
||||
try { clientToDispose.UninitializeConsumer(); } catch { /* swallow */ }
|
||||
if (Marshal.IsComObject(clientToDispose))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(clientToDispose); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,11 @@
|
||||
<Private>false</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="aaAlarmManagedClient">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
|
||||
<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>
|
||||
<Reference Include="Interop.WNWRAPCONSUMERLib">
|
||||
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user