probe: GetStatistics polling viable, Galaxy has no active alarms today

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 07:16:08 -04:00
parent 12881ca791
commit 3ff4969224
2 changed files with 116 additions and 11 deletions
@@ -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>();
int[] positions = Array.Empty<int>();
int[] handles = Array.Empty<int>();
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) : "<null>";
string posStr = positions != null ? string.Join(",", positions) : "<null>";
string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
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() ?? "<null>";
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