From 3ff4969224fe14e8f07b6eb0d134465d467d504c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 1 May 2026 07:16:08 -0400 Subject: [PATCH] probe: GetStatistics polling viable, Galaxy has no active alarms today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extended AlarmClientWmProbeTests.ProbeAlarmClientWmMessages to also call GetStatistics every ~2s during the pump window. Re-ran on the dev rig 2026-05-01: - GetStatistics is safely callable from the same thread that did RegisterConsumer + Subscribe. Every poll (9 calls / 20s window) returned rc=0, no exceptions. - Galaxy currently has zero active alarms. total=0 active=0 suppressed=0 newAlarms=0 across every poll. positions[] and handles[] arrays were empty. - changes=1 codes=[7] was constant across all polls, matching the constant 1 Hz WM 0xC275 cadence — same heartbeat semantics exposed through both the WM path and the pull API. Confirms the polling design is mechanically viable: GetStatistics threading-affinity is fine and the call is cheap. The remaining unknown is whether GetStatistics populates positions[] / handles[] with real entries when an alarm actually fires. Proving that requires triggering an alarm — next probe is an MxAccess write to a $Alarm-extended boolean tag (reference pending). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/AlarmClientDiscovery.md | 46 ++++++++--- .../AlarmClientWmProbeTests.cs | 81 ++++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index 3c067d5..fe1f42c 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -157,20 +157,48 @@ AVEVA's own message-pump thread — confirmable by extending the probe to fire `GetStatistics` on its own thread and check the result. +## GetStatistics polling — second probe run, 2026-05-01 + +Extended the probe to call `GetStatistics` every ~2s alongside the +WM logger. Key findings: + +- **`GetStatistics` is safely callable from the same thread that + did `RegisterConsumer` + `Subscribe`.** Every poll returned rc=0 + with no exceptions over 9 polls / 20s window. +- **The deployed Galaxy currently has zero active alarms.** Every + poll reported `total=0 active=0 suppressed=0 newAlarms=0`. The + `positions[]` and `handles[]` arrays were empty. +- **`changes=1 codes=[7]` was constant across all polls**, matching + the constant 1 Hz WM 0xC275 cadence. Code 7 is consistent with a + "heartbeat / subscription healthy" sentinel — same semantics as + the WM but reported through the pull-side API. +- `percent=100` (query-complete percentage) was constant — the + subscription is steady-state. + +This confirms the polling design (option 1 in the previous section) +is mechanically viable. The remaining open question is whether +`GetStatistics` populates `positions[] / handles[]` with real +entries when an alarm transition actually fires — proving that +requires firing an alarm. + ## 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 +1. **Fire a real Galaxy alarm during the pump window.** The cleanest + programmatic trigger is an MxAccess write that flips a + `$Alarm`-extended boolean to true (alarm in) and back to false + (alarm out). Pinning the exact tag reference is pending — needs + either a documented test-fixture tag or an interactive selection + in System Platform IDE. Once the trigger fires, this resolves + whether AVEVA's pulled change set arrives via `GetStatistics` + `positions[] / handles[]` (per-change polling works) or only via + the AVEVA-internal window (callback path needed). +2. **Hook AVEVA's internal window** to log what WMs it actually + processes — only relevant if probe 1 shows `GetStatistics` does + NOT report per-change activity. +3. **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 diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs index a9ba7b3..5659d6c 100644 --- a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs +++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs @@ -113,7 +113,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable this.output = output; } - [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (with live Galaxy) to capture AVEVA WM_APP message IDs")] + [Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (with live Galaxy) to capture AVEVA WM_APP message IDs + GetStatistics polling results")] public void ProbeAlarmClientWmMessages() { // 1. Pre-resolve a few candidate RegisterWindowMessage strings so any @@ -241,8 +241,13 @@ public sealed class AlarmClientWmProbeTests : IDisposable // 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). + // WM_GETICON spam from typical pumps). Every ~2s also call + // GetStatistics and snapshot up to N records, to test the + // polling design — if Galaxy has any active alarms or any + // have changed since Subscribe, we'll see them here. DateTime deadline = DateTime.UtcNow + PumpDuration; + DateTime nextPoll = DateTime.UtcNow + TimeSpan.FromSeconds(2); + int pollCount = 0; while (DateTime.UtcNow < deadline) { while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE)) @@ -251,6 +256,11 @@ public sealed class AlarmClientWmProbeTests : IDisposable TranslateMessage(ref msg); DispatchMessage(ref msg); } + if (DateTime.UtcNow >= nextPoll) + { + PollGetStatistics(client, ++pollCount); + nextPoll = DateTime.UtcNow + TimeSpan.FromSeconds(2); + } Thread.Sleep(10); } @@ -274,6 +284,73 @@ public sealed class AlarmClientWmProbeTests : IDisposable } } + private void PollGetStatistics(AlarmClient client, int seq) + { + try + { + int percent = 0, total = 0, active = 0, suppressed = 0; + int suppressedFilters = 0, newAlarms = 0, changes = 0; + int[] codes = Array.Empty(); + int[] positions = Array.Empty(); + int[] handles = Array.Empty(); + int rc = client.GetStatistics( + ref percent, ref total, ref active, ref suppressed, + ref suppressedFilters, ref newAlarms, ref changes, + ref codes, ref positions, ref handles); + string codesStr = codes != null ? string.Join(",", codes) : ""; + string posStr = positions != null ? string.Join(",", positions) : ""; + string handlesStr = handles != null ? string.Join(",", handles) : ""; + int posLen = positions?.Length ?? 0; + Log($"GetStatistics #{seq} rc={rc} pct={percent} total={total} active={active} " + + $"suppressed={suppressed} suppressedFilters={suppressedFilters} new={newAlarms} changes={changes} " + + $"codes=[{codesStr}] positions=[{posStr}] handles=[{handlesStr}]"); + + // If positions has entries, fetch one record so we see the + // record-shape AVEVA exposes for a real alarm. + if (posLen > 0 && positions != null) + { + int idx = positions[0]; + AlarmRecord rec = new AlarmRecord(); + int recRc = client.GetAlarmExtendedRec(idx, ref rec); + Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " + + DescribeAlarmRecord(rec)); + } + } + catch (Exception ex) + { + Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static string DescribeAlarmRecord(AlarmRecord rec) + { + // Reflect over the record's public properties so we don't have to + // guess the field shape — the discovery probe already showed it has + // ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc. + var sb = new System.Text.StringBuilder(); + sb.Append("{ "); + bool first = true; + foreach (var prop in rec.GetType().GetProperties( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)) + { + try + { + object? v = prop.GetValue(rec); + string vs = v?.ToString() ?? ""; + if (vs.Length > 50) vs = vs.Substring(0, 47) + "..."; + if (!first) sb.Append(", "); + sb.Append($"{prop.Name}={vs}"); + first = false; + } + catch + { + // skip failing accessors + } + } + sb.Append(" }"); + return sb.ToString(); + } + private void LogIfInteresting(MSG m) { // Filter out the highest-volume noise (timer ticks, paint, mouse moves