Files
mxaccessgw/docs/AlarmClientDiscovery.md
T
Joseph Doherty 3ff4969224 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>
2026-05-01 07:16:08 -04:00

9.7 KiB

aaAlarmManagedClient discovery — public surface, 2026-05-01

Result of running MxGateway.Worker.Tests.AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface against the deployed AVEVA assembly:

  • File: C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll
  • Assembly identity: aaAlarmManagedClient, Version=1.0.7368.41290, Culture=neutral, PublicKeyToken=7ebd82b507d9e10c

Public types

  • aaAlarmManagedClient.AlarmClient (class)
  • aaAlarmManagedClient.PriorityData (class)

That's the entire exported surface — two types, no interfaces, no delegates.

AlarmClient events

None. The class has no public events at all. The reflection probe's GetEvents(BindingFlags.Public | Instance | Static) returned an empty list.

AlarmClient methods (relevant subset)

  • Lifecycle: RegisterConsumer(int hWnd, string szProductName, string szApplicationName, string szVersion, bool bRetainHiddenAlarms) → int, DeregisterConsumer() → int, InitializeConsumer(string szApplicationName) → int, UninitializeConsumer() → int, Dispose().
  • Subscription: Subscribe(string szSubscription, short wFromPri, short wToPri, eQueryType QueryType, eSortFlags SortFlags, eAlarmFilterState FilterMask, eAlarmFilterState FilterSpecification) → int.
  • Change enumeration (pull on poke): GetStatistics(out int lPercentQuery, out int lTotalAlarms, out int lActiveAlarms, out int lSuppressedAlarms, out int lSuppressedFilters, out int lNewAlarms, out int lChangesCount, out int[] ChangeCodes, out int[] ChangePos, out int[] hAlarm) → int.
  • Record fetch: GetAlarmExtendedRec(int lIndex, out AlarmRecord almRec) → int, GetAlarmExtendedRec2(...), GetHighPriAlarm(out AlarmRecord almRec) → int.
  • Selection model (used by ack-selected-* family): DeselectAll, SelectAlaramEntry(short select, int from, int to), SelectByGUID(Guid), SelectAlarmCount(int from, int to).
  • Acknowledge: AlarmAckByGUID(Guid alarmGuid, string ackComment, string ackOprName, string ackOprNode, string ackOprDomain, string ackOprFullName) → int is the per-alarm full-fidelity native ack. AlarmAckSelected(string ackComment, string ackOprName, string ackOprNode, string ackOprDomain, string ackOprFullName) → int acks whatever the selection model currently has selected. Several AckSelected*Group/Tag/Priority/All/Visible*Alarms_Ex(...) variants exist for bulk ack scoped to a group / tag / priority range.
  • Suppress / shelve: SupressSelected* and ShelveSelected* families plus DoAlarmShelveAction(...). Out of scope for the v1 alarm path.
  • Snapshot/filter (SF* prefix): SFSetSortA / SFSetFilterA / SFCreateSnapshot / SFGetListCount / SFDeleteSnapshot / SFRefreshAlarm / SFGetStatistics. Snapshot-style query API, distinct from the consumer-subscription path. Not currently used.

What this means

The architecture comment on src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs (PR A.5) is wrong against this deployed assembly:

"The AVEVA alarm-manager surface (IAlarmMgrDataProvider) exposes the events we need as plain .NET events — no Windows message pump required."

There is no managed event surface. AlarmClient.RegisterConsumer takes an hWnd because WM_APP messaging is the actual notification mechanism: AVEVA's alarm provider WM_APP-pokes the registered window, and the consumer is expected to call GetStatistics on each poke to pull ChangeCodes / ChangePos / hAlarm arrays, then GetAlarmExtendedRec(pos, …) per index to fetch each changed record.

AlarmClientConsumer.AlarmRecordReceived has no production callers as 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.

Live runtime probe — 2026-05-01

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.

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.

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. 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 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 notification mechanism.