Tried every documented subscription knob with InitializeConsumer present + provider visible at status 100: - qtSummary AND qtHistory (the only eQueryType values). - Priority 1..999 AND 0..32767. - FilterMask/Spec asNone AND asAlarmActiveNow. eAlarmFilterState is single-state-valued (asNone=0, asAlarmActiveNow=1, asAlarmAcked=2, asShelved=3), not flag bits, so the filter surface is exhausted. GetStatistics continued to report total=0 active=0 codes=[7] for every poll across all combinations. User confirmation: the BoolAlarm extension on TestMachine_001.TestAlarm001 is evaluating (the $Alarm.InAlarm sub-attribute flips true/false in lockstep with the script writes, visible in aaObjectViewer). So the consumer chain is verified working end-to-end on our side. What's missing is producer-side publication into the aaAlarmManagedClient stream. Probable causes (config, not code): - BoolAlarm extension's "publish to alarm manager" / "Active" / "Enabled" flag may be off. - Alarm-vs-event mode setting may have it routing to events, not alarms. - Platform alarm area may not match the consumer's subscription scope. Resolution path: check the BoolAlarm extension's config in System Platform IDE; check aaObjectViewer's Active Alarms panel (not attribute panel) to see if the alarm appears there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 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) → intis the per-alarm full-fidelity native ack.AlarmAckSelected(string ackComment, string ackOprName, string ackOprNode, string ackOprDomain, string ackOprFullName) → intacks whatever the selection model currently has selected. SeveralAckSelected*Group/Tag/Priority/All/Visible*Alarms_Ex(...)variants exist for bulk ack scoped to a group / tag / priority range. - Suppress / shelve:
SupressSelected*andShelveSelected*families plusDoAlarmShelveAction(...). 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:
- Polling. Just call
GetStatisticson 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. - Hook AVEVA's internal window. Discover AVEVA's own window
(
hwnd=0x18032Ein the probe),SetWindowsHookExorSetWindowSubclasson 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.
Alarm-provider visibility — third probe run, 2026-05-01
Extended the probe to call AlarmClient.GetProviders after
RegisterConsumer. Result on this rig:
GetProviders -> rc=0 count=0 list=[]
Zero alarm providers visible to the consumer process. This
explains every preceding probe run: no providers means no alarm
events, regardless of how many times any value (including a
bool with an $Alarm extension) flips. Subscribe(@"\Galaxy!")
returns 0 (success) but matches nothing because the alarm-manager
chain that provides the matching feed doesn't expose any provider
to this consumer.
A System Platform script flipping TestMachine_001.TestAlarm001
every 10s during this probe run produced no observable
GetStatistics transitions, no positions[] / handles[]
entries, no change in any field — confirms the silence is not
about subscription-scope / message-pump but about provider
absence.
Possible causes
- No
$Alarmextension on the test bool. IfTestMachine_001.TestAlarm001is a regular UDA without aBoolAlarmextension wired to it, flipping the value just writes a new value — no alarm fires. - Alarm manager service not running. AVEVA's
aaAlarmMgr(or the equivalent on this rig's Platform version) needs to be running for providers to register. - Process security context. A consumer running under a
normal user account may not see providers that registered
under
LocalSystem/ a Platform service identity. The gateway-worker installation runs under a service account that may have access wheredotnet testdoesn't.
InitializeConsumer required — fourth probe run, 2026-05-01
Adding InitializeConsumer("AlarmProbe.Tests") before
RegisterConsumer made \Galaxy! appear in GetProviders
(count=1, status 0 → 100 within 500ms). So #2 and #3 above are
NOT the cause — the consumer can see the alarm provider once it
calls Initialize. That's a missing API-call ordering, not a
permission or service issue.
InitializeConsumer -> 0
RegisterConsumer -> 0
GetProviders [after Register] -> rc=0 count=0 list=[]
Subscribe('\Galaxy!') -> 0
GetProviders [after Subscribe] -> rc=0 count=1 list=[ 0 \Galaxy!]
GetProviders [poll #1] -> rc=0 count=1 list=[100 \Galaxy!]
Despite the provider being visible at "100% query complete" for
the entire 60s window, GetStatistics continued to report
total=0 active=0 codes=[7] — no alarm transitions reached the
consumer even with a System Platform script flipping the test
boolean every 10s during the run.
That isolates the remaining unknown to whether the test bool's alarm extension is actually generating MxAccess alarm-provider events when its value flips. The probe has confirmed every link in the consumer chain works (Initialize → Register → Subscribe → provider visible at 100%) — what's missing is alarm traffic from the producer side. ObjectViewer or another live consumer running alongside the script is the next discriminator: does it visibly see the alarm fire?
API-ordering finding: InitializeConsumer MUST precede
RegisterConsumer (or at least, must be called before
GetProviders returns anything). PR A.5's AlarmClientConsumer
omits InitializeConsumer entirely — that's a bug fix to apply
even before A.2 lands, since without it the provider chain never
becomes visible.
Subscribe-parameter sweep — fifth probe run, 2026-05-01
Even with InitializeConsumer + provider visible at status 100,
no alarm transitions arrived during a 60s window with the user's
script flipping the test bool every 10s. Tried:
qtSummaryandqtHistory(the onlyeQueryTypevalues).- Priority 1..999 and 0..32767.
eAlarmFilterState.asNoneandasAlarmActiveNowfor bothFilterMaskandFilterSpecification.
eAlarmFilterState is single-state-valued (asNone=0,
asAlarmActiveNow=1, asAlarmAcked=2, asShelved=3), not flag bits.
None of these knobs surfaced any alarm activity.
User confirmation 2026-05-01: the test bool does have a
BoolAlarm extension on it; in aaObjectViewer the
$Alarm.InAlarm sub-attribute flips true/false in lockstep with
the script's writes. So the alarm extension is evaluating
its condition, just not visibly producing transitions on the
aaAlarmManagedClient consumer stream.
This isolates the unknown to the producer-side path — whether
the BoolAlarm extension's "publish to alarm manager" knob is on,
whether the platform is in an alarm area that matches the
consumer's subscription scope, or whether AVEVA has a separate
"events" path the BoolAlarm uses by default that this consumer
doesn't subscribe to. Resolving requires checking the BoolAlarm
extension's config in System Platform IDE (alarm priority,
category, "Active"/"Enabled" flags, alarm-vs-event mode) and
checking whether aaObjectViewer's Active Alarms panel sees the
alarm fire.
Implications for A.2 implementation
The A.2 PR's value is unmeasurable until at least one alarm
provider is visible. The choice between polling-via-GetStatistics
and the callback path can only be decided by observing what
populates first when a real alarm fires. Without a provider,
both paths return the same "nothing happening" answer.
Until that's resolved, A.2 implementation work is genuinely blocked on a dev-rig configuration issue — not on architectural choice or code structure.
GetStatistics polling — second probe run, 2026-05-01
Extended the probe to call GetStatistics every ~2s alongside the
WM logger. Key findings:
GetStatisticsis safely callable from the same thread that didRegisterConsumer+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. Thepositions[]andhandles[]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:
- 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 viaGetStatisticspositions[] / handles[](per-change polling works) or only via the AVEVA-internal window (callback path needed). - Hook AVEVA's internal window to log what WMs it actually
processes — only relevant if probe 1 shows
GetStatisticsdoes NOT report per-change activity. - Decompile
aaAlarmManagedClient.dll's IL for theRegisterConsumermethod to find whatRegisterWindowMessagestring is used and whether there's a callback-registration surface onWNAL_Registerthat 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.