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; /// /// 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 GetXmlCurrentAlarms2 while a System Platform script flips /// TestMachine_001.TestAlarm001 every 10s. The XML payload bypasses /// the FILETIME→DateTime auto-marshaling that crashes /// aaAlarmManagedClient.AlarmClient.GetHighPriAlarm. /// /// Skip-gated; flip Skip=null to run on the dev rig. /// 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 = "" + "" + $"{Environment.MachineName}" + "Galaxy" + "DEV" + "" + ""; 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 log = new ConcurrentQueue(); 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() ?? "", 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() ?? ""; 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() ?? ""; 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]"; } }