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:
Joseph Doherty
2026-05-01 09:44:15 -04:00
parent f490ae2593
commit f711a55be4
13 changed files with 1326 additions and 318 deletions
@@ -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 */ }
}
}
}
}