f711a55be4
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>
288 lines
12 KiB
C#
288 lines
12 KiB
C#
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]";
|
|
}
|
|
}
|