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:
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using WNWRAPCONSUMERLib;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace MxGateway.Worker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
|
||||
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
|
||||
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
|
||||
/// subscribe to the dev rig's `\\<machine>\Galaxy!DEV` provider, and
|
||||
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
|
||||
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
|
||||
/// the FILETIME→DateTime auto-marshaling that crashes
|
||||
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
|
||||
///
|
||||
/// Skip-gated; flip Skip=null to run on the dev rig.
|
||||
/// </summary>
|
||||
public sealed class WnWrapConsumerProbeTests
|
||||
{
|
||||
private static readonly string MachineName = Environment.MachineName;
|
||||
private static readonly string SubscriptionExpression =
|
||||
$@"\\{MachineName}\Galaxy!DEV";
|
||||
|
||||
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
|
||||
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
|
||||
private static readonly string XmlAlarmQuery =
|
||||
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
|
||||
"<QUERY>" +
|
||||
$"<NODE>{Environment.MachineName}</NODE>" +
|
||||
"<PROVIDER>Galaxy</PROVIDER>" +
|
||||
"<GROUP>DEV</GROUP>" +
|
||||
"</QUERY>" +
|
||||
"</QUERIES>";
|
||||
|
||||
private const int MaxAlarmsPerFetch = 100;
|
||||
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
|
||||
private readonly Stopwatch elapsed = Stopwatch.StartNew();
|
||||
|
||||
public WnWrapConsumerProbeTests(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
|
||||
public void ProbeWnWrapConsumer()
|
||||
{
|
||||
Exception? threadException = null;
|
||||
var done = new ManualResetEventSlim(false);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { RunProbe(); }
|
||||
catch (Exception ex) { threadException = ex; }
|
||||
finally { done.Set(); }
|
||||
});
|
||||
thread.IsBackground = false;
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
done.Wait();
|
||||
thread.Join();
|
||||
|
||||
output.WriteLine($"Captured {log.Count} log line(s):");
|
||||
while (log.TryDequeue(out string? line))
|
||||
{
|
||||
output.WriteLine(line);
|
||||
}
|
||||
|
||||
if (threadException != null)
|
||||
{
|
||||
throw threadException;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe()
|
||||
{
|
||||
wwAlarmConsumerClass? client = null;
|
||||
try
|
||||
{
|
||||
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
|
||||
client = new wwAlarmConsumerClass();
|
||||
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
|
||||
|
||||
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
|
||||
// MUST precede RegisterConsumer for the alarm provider to become
|
||||
// visible. The wnwrap surface mirrors that requirement.
|
||||
try
|
||||
{
|
||||
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
|
||||
Log($"InitializeConsumer -> {init}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// hWnd=0 — XML pull-based; no message pump needed.
|
||||
int reg = client.RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: "MxGatewayProbe",
|
||||
szApplicationName: "MxGatewayProbe.WnWrap",
|
||||
szVersion: "1.0");
|
||||
Log($"RegisterConsumer(hWnd=0) -> {reg}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Try both subscription mechanisms: classic Subscribe (canonical
|
||||
// scope from prior aaAlarmManagedClient probe), and
|
||||
// SetXmlAlarmQuery (the wnwrap-native filter format).
|
||||
try
|
||||
{
|
||||
int sub = client.Subscribe(
|
||||
szSubscription: SubscriptionExpression,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
|
||||
client.SetXmlAlarmQuery(XmlAlarmQuery);
|
||||
Log("SetXmlAlarmQuery -> ok");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Echo the query back so we can confirm what the consumer is
|
||||
// actually filtering on (provider may rewrite or reject some
|
||||
// attributes silently).
|
||||
try
|
||||
{
|
||||
object echo = string.Empty;
|
||||
client.GetXmlAlarmQuery(out echo);
|
||||
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
|
||||
// every change in payload. Run for PumpDuration. The user's flip
|
||||
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
|
||||
// least 2-3 transitions over a 30s window.
|
||||
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
|
||||
DateTime deadline = DateTime.UtcNow + PumpDuration;
|
||||
DateTime nextPoll = DateTime.UtcNow;
|
||||
int pollCount = 0;
|
||||
string lastV2 = string.Empty;
|
||||
string lastV1 = string.Empty;
|
||||
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
|
||||
int statsOk = 0, statsThrow = 0;
|
||||
string lastStats = string.Empty;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (DateTime.UtcNow >= nextPoll)
|
||||
{
|
||||
pollCount++;
|
||||
|
||||
// V2 channel.
|
||||
try
|
||||
{
|
||||
object xml2 = string.Empty;
|
||||
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
|
||||
v2Ok++;
|
||||
string s = xml2?.ToString() ?? "<null>";
|
||||
if (s != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV2 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v2Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV2)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
|
||||
lastV2 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// V1 channel — different vtable slot; either may be the
|
||||
// populated one in this AVEVA build.
|
||||
try
|
||||
{
|
||||
object xml1 = string.Empty;
|
||||
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
|
||||
v1Ok++;
|
||||
string s = xml1?.ToString() ?? "<null>";
|
||||
if (s != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
|
||||
lastV1 = s;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
v1Throw++;
|
||||
string es = $"{ex.GetType().Name}: {ex.Message}";
|
||||
if (es != lastV1)
|
||||
{
|
||||
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
|
||||
lastV1 = es;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats channel — heartbeat + active-count even if the XML
|
||||
// calls are dry, this surfaces whether wnwrap sees any
|
||||
// alarms in the subscribed scope at all.
|
||||
try
|
||||
{
|
||||
int pct, total, active, newAlms, changes;
|
||||
client.GetStatistics(
|
||||
out pct, out total, out active, out newAlms, out changes,
|
||||
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
statsOk++;
|
||||
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
|
||||
if (statsSummary != lastStats)
|
||||
{
|
||||
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
|
||||
lastStats = statsSummary;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statsThrow++;
|
||||
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
nextPoll = DateTime.UtcNow + PollInterval;
|
||||
}
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
|
||||
|
||||
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
|
||||
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
|
||||
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
|
||||
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (client != null && Marshal.IsComObject(client))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string line)
|
||||
{
|
||||
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
|
||||
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user