diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index ef0c95a..3c067d5 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -87,35 +87,97 @@ a result — `RaiseAlarmRecordReceived` is `internal` for tests and never gets invoked at runtime. Until A.2 lands a WM_APP pump, `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` cannot carry events. -## Implications for A.2 +## Live runtime probe — 2026-05-01 -The original plan banner was right: A.2 needs a hidden message-only -window inside the worker's STA whose hWnd is passed to -`RegisterConsumer`, with a `WindowProc` that intercepts the AVEVA -WM_APP message and routes change-enumeration into -`MxAccessAlarmEventSink.EnqueueTransition`. Open questions before -implementation: +`MxGateway.Worker.Tests.AlarmClientWmProbeTests.ProbeAlarmClientWmMessages` +is a Skip-gated runtime probe that creates a real message-only +window, calls `AlarmClient.RegisterConsumer(hWnd, …)` + +`Subscribe(@"\Galaxy!", …)`, and pumps for 20s while logging every +window message that arrives. Run results below — this turned the +"WM_APP pump" design assumption upside down. -1. **WM_APP message ID.** Not in the public surface — needs either - AVEVA's C++ Toolkit reference (canonical doc per `gateway.md`) or - a runtime probe (subclass a window, log every WM arriving while a - live alarm is fired, identify the AVEVA one). Worth doing once on - the dev rig and checking the result in. -2. **`wParam` / `lParam` semantics.** Probably none — the pattern is - "got poked; pull state via `GetStatistics`." Confirm during the - probe. -3. **Threading.** AVEVA almost certainly delivers the WM on the - thread that owns the window. The worker's STA is the natural - home; the existing `StaRuntime` already runs a pump there. If - AVEVA assumes a UI thread (MTA-incompatible call paths inside - `GetStatistics`), the alarm path may need its own STA. -4. **Subscription scope.** `AlarmClient.Subscribe(szSubscription, - …)` takes an AVEVA-syntax string for the alarm provider (e.g. - `"\Galaxy!"` for a whole Galaxy or `"\Galaxy!Group.Tag"` for a - subset). The configured Galaxy name is already known to the - worker via the existing data session — reuse it. +**`RegisterConsumer` and `Subscribe` both returned 0 (success).** The +calls are valid against the deployed assembly; no parameter pinning +needed. + +**A registered-message-class WM (ID `0xC275` in this OS session) +fired every ~1s after `Subscribe` completed.** Constant +`wParam = 0x00001100`, constant `lParam = 0x079E46D8` (looks like a +stable pointer into AVEVA-internal state) for all 20 hits. The +constant payload across hits with no Galaxy alarm being fired +suggests this is a **heartbeat/keepalive**, not a per-change +notification. + +**Critically: this WM is delivered to AVEVA's own internal window +(`hwnd=0x18032E`) — NOT to the consumer's `hWnd` we passed in.** The +consumer window's `WndProc` received only the standard creation +sequence (`WM_GETMINMAXINFO`, `WM_NCCREATE`, `WM_NCCALCSIZE`, +`WM_CREATE`) and the destruction sequence (`WM_NCDESTROY`, +`WM_DESTROY`, `WM_NCCALCSIZE`) — nothing in between. AVEVA's +notification path runs entirely against AVEVA's internal window; +it never forwards to the user-supplied hWnd. + +The message ID itself is dynamic (a `RegisterWindowMessage` +allocation in the >= 0xC000 range), so it cannot be hard-coded — +each consumer process must call `RegisterWindowMessage` with the +correct *string* and use whatever ID the OS returns. + +## What this means for A.2 + +The "WM_APP pump on the user hWnd" design — what the original plan +banner described and what the previous version of this doc +recommended — does not match how AVEVA actually delivers +notifications. The hWnd parameter to `RegisterConsumer` does not +appear to receive any of AVEVA's alarm traffic; it's likely used +only as a registration identity (and perhaps as a parent for modal +dialogs). + +Two viable A.2 designs given the probe data: + +1. **Polling.** Just call `GetStatistics` on a timer (e.g. every + 500ms in the worker's STA) and react to the change set it + reports. No window plumbing needed. Trade-off: latency floor = + poll period; modest CPU floor because the call is cheap. Matches + the heartbeat-style WM 0xC275 semantics — AVEVA itself runs a + poll loop internally. +2. **Hook AVEVA's internal window.** Discover AVEVA's own window + (`hwnd=0x18032E` in the probe), `SetWindowsHookEx` or + `SetWindowSubclass` on it, and intercept WM 0xC275 on AVEVA's + thread. Higher fidelity, near-zero latency, but invasive, + fragile across AVEVA upgrades, and requires running on the same + process / thread as the AVEVA window. Probably a non-starter + without further AVEVA documentation. + +**Recommendation:** the polling path (option 1) is cheaper to +implement, more robust against AVEVA-internal change, and +acceptable for a typical alarm cadence. The worker's existing STA +already provides a thread-affinitized timer surface. The unanswered +question is whether `GetStatistics` can be safely called outside +AVEVA's own message-pump thread — confirmable by extending the +probe to fire `GetStatistics` on its own thread and check the +result. + +## Open follow-up probes + +Each can be added to `AlarmClientWmProbeTests` as a separate +Skip-gated test: + +1. **Fire a real Galaxy alarm during the pump window.** Confirms + whether the WM 0xC275 cadence changes (becomes per-change rather + than periodic) and whether `GetStatistics` returns a non-empty + `ChangeCodes / ChangePos / hAlarm` triple. +2. **Call `GetStatistics` on a different thread from the + `RegisterConsumer` thread** to test threading affinity. +3. **Hook AVEVA's internal window** to log what WMs it actually + processes (would resolve option 2 above). +4. **Decompile `aaAlarmManagedClient.dll`'s IL** for the + `RegisterConsumer` method to find what `RegisterWindowMessage` + string is used and whether there's a callback-registration + surface on `WNAL_Register` that the managed client wraps. The + alarmlst.dll strings (`WNAL_CallBack`, "Invalid callbacks" error) + suggest the underlying C API takes callbacks, but the managed + wrapper exposes none of them. PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms` -are correct — they're pull-style and don't depend on the missing -event surface. The event-subscription wiring is what has to be -replaced. +are correct — they're pull-style and don't depend on the +notification mechanism. diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs new file mode 100644 index 0000000..a9ba7b3 --- /dev/null +++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using AlarmMgrDataProviderCOM; +using aaAlarmManagedClient; +using Xunit.Abstractions; + +namespace MxGateway.Worker.Tests; + +/// +/// Runtime probe — registers as an AlarmClient consumer with a real +/// hidden message-only window, subscribes to a Galaxy alarm provider, +/// and logs every Win32 message that arrives during a fixed pump +/// window. The intent is to identify the WM_APP / RegisterWindowMessage +/// ID that AVEVA's alarm provider posts when alarms change, plus the +/// wParam/lParam semantics on each. +/// +/// Skip-gated by default; flip Skip=null and run against the live dev +/// rig to capture output. Requires: +/// +/// A reachable Galaxy with at least one alarmable object. +/// The configured Galaxy expression below to match a real provider (default "\\Galaxy" — adjust if needed). +/// An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured. +/// +/// +public sealed class AlarmClientWmProbeTests : IDisposable +{ + // Probe configuration. Override in the constructor below if needed. + private const string SubscriptionExpression = @"\Galaxy!"; + private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(20); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")] + private static extern IntPtr CreateWindowEx( + int dwExStyle, string lpClassName, string lpWindowName, + int dwStyle, int X, int Y, int nWidth, int nHeight, + IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyWindow(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance); + + [DllImport("user32.dll")] + private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); + + [DllImport("user32.dll")] + private static extern IntPtr DispatchMessage(ref MSG lpMsg); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TranslateMessage(ref MSG lpMsg); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern uint RegisterWindowMessage(string lpString); + + private const int HWND_MESSAGE = -3; + private const uint PM_REMOVE = 0x0001; + + private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WNDCLASSW + { + public uint style; + public IntPtr lpfnWndProc; + public int cbClsExtra; + public int cbWndExtra; + public IntPtr hInstance; + public IntPtr hIcon; + public IntPtr hCursor; + public IntPtr hbrBackground; + [MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName; + [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public IntPtr hwnd; + public uint message; + public IntPtr wParam; + public IntPtr lParam; + public uint time; + public int x; + public int y; + } + + private readonly ITestOutputHelper output; + private readonly ConcurrentQueue log = new ConcurrentQueue(); + private readonly Stopwatch elapsed = Stopwatch.StartNew(); + private GCHandle wndProcHandle; + private IntPtr probeWindow = IntPtr.Zero; + private string? registeredClass; + + public AlarmClientWmProbeTests(ITestOutputHelper output) + { + this.output = output; + } + + [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (with live Galaxy) to capture AVEVA WM_APP message IDs")] + public void ProbeAlarmClientWmMessages() + { + // 1. Pre-resolve a few candidate RegisterWindowMessage strings so any + // matches in the captured log can be labeled. None of these is + // confirmed; we record what each resolves to so the actual AVEVA + // message ID (whatever it turns out to be) can be cross-referenced. + string[] candidateNames = + { + "WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm", + "WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify", + "WNAL_Notify", "WNAL_ChangeNotification", + "AlarmManager.Notify", "AlarmManagerNotify", + "ArchestrA.AlarmChange", "AVEVA.AlarmNotify", + "aaAlarmManagedClient.Notify", + "GotAlarmChanges", "OnAlarmChanges", + }; + foreach (string name in candidateNames) + { + uint id = RegisterWindowMessage(name); + output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})"); + } + output.WriteLine(""); + + // 2. Spin up a single STA-affinitized thread, create a hidden message- + // only window owned by it, run RegisterConsumer + Subscribe against + // that window's hWnd, then pump messages on that thread for the + // configured duration. Threading discipline matches the worker's + // StaRuntime model. + Exception? threadException = null; + var pumpDone = new ManualResetEventSlim(false); + var thread = new Thread(() => + { + try + { + RunProbe(); + } + catch (Exception ex) + { + threadException = ex; + } + finally + { + pumpDone.Set(); + } + }); + thread.IsBackground = false; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + pumpDone.Wait(); + thread.Join(); + + // 3. Drain the log to xunit output regardless of outcome — partial + // captures are still informative. + output.WriteLine(""); + 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() + { + // 3a. Register a window class and create a message-only window. + WndProc wndProc = ProbeWndProc; + wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive + + registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N"); + var cls = new WNDCLASSW + { + style = 0, + lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc), + hInstance = GetModuleHandle(null!), + lpszClassName = registeredClass, + }; + ushort atom = RegisterClassW(ref cls); + if (atom == 0) + { + int err = Marshal.GetLastWin32Error(); + Log($"RegisterClass failed err=0x{err:X8}"); + return; + } + Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}"); + + probeWindow = CreateWindowEx( + dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe", + dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0, + hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero, + hInstance: cls.hInstance, lpParam: IntPtr.Zero); + if (probeWindow == IntPtr.Zero) + { + int err = Marshal.GetLastWin32Error(); + Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}"); + return; + } + Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}"); + + // 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer + // accepts an int hWnd — narrow the IntPtr (sufficient on x86). + AlarmClient? client = null; + try + { + client = new AlarmClient(); + int register = client.RegisterConsumer( + hWnd: probeWindow.ToInt32(), + szProductName: "AlarmProbe", + szApplicationName: "AlarmProbe.Tests", + szVersion: "1.0", + bRetainHiddenAlarms: false); + Log($"RegisterConsumer -> {register}"); + + int subscribe = client.Subscribe( + szSubscription: SubscriptionExpression, + wFromPri: 1, wToPri: 999, + QueryType: eQueryType.qtSummary, + SortFlags: eSortFlags.sfReturnNewestFirst, + FilterMask: eAlarmFilterState.asNone, + FilterSpecification: eAlarmFilterState.asNone); + Log($"Subscribe('{SubscriptionExpression}') -> {subscribe}"); + + // 3c. Pump for the configured duration. Log every message we see + // (filtered light to avoid noise from WM_PAINT / WM_TIMER / + // WM_GETICON spam from typical pumps). + DateTime deadline = DateTime.UtcNow + PumpDuration; + while (DateTime.UtcNow < deadline) + { + while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE)) + { + LogIfInteresting(msg); + TranslateMessage(ref msg); + DispatchMessage(ref msg); + } + Thread.Sleep(10); + } + + Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering."); + + try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); } + catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); } + } + finally + { + try { client?.Dispose(); } catch { /* swallow */ } + if (probeWindow != IntPtr.Zero) + { + DestroyWindow(probeWindow); + probeWindow = IntPtr.Zero; + } + if (registeredClass != null) + { + UnregisterClassW(registeredClass, GetModuleHandle(null!)); + } + } + } + + private void LogIfInteresting(MSG m) + { + // Filter out the highest-volume noise (timer ticks, paint, mouse moves + // from a desktop session). Keep WM_USER..WM_APP+ entirely; those are + // the candidates for the AVEVA-registered message. + const uint WM_PAINT = 0x000F; + const uint WM_TIMER = 0x0113; + const uint WM_MOUSEMOVE = 0x0200; + const uint WM_NCMOUSEMOVE = 0x00A0; + if (m.message == WM_PAINT || m.message == WM_TIMER || + m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE) + { + return; + } + + string interpreted = InterpretMessageId(m.message); + Log(string.Format( + "WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}", + m.message, interpreted, + m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF, + m.hwnd.ToInt64())); + } + + private static string InterpretMessageId(uint id) + { + if (id < 0x0400) return "WM_"; + if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}"; + if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}"; + return $"RegisterWindowMessage_0x{id:X4}"; + } + + private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) + { + // Log every WM that lands on the probe window itself. + string interpreted = InterpretMessageId(msg); + Log(string.Format( + "WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}", + msg, interpreted, + wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF)); + return DefWindowProcW(hWnd, msg, wParam, lParam); + } + + private void Log(string line) + { + log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}"); + } + + public void Dispose() + { + if (wndProcHandle.IsAllocated) wndProcHandle.Free(); + } +} diff --git a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj index 47796e3..bb2f949 100644 --- a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj +++ b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj @@ -25,4 +25,17 @@ + + + C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll + true + false + + + C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll + true + false + + +