docs: AlarmClient public surface — managed-event premise wrong, WM_APP required

Reflection probe of the deployed aaAlarmManagedClient.dll
(v1.0.7368.41290) on 2026-05-01 confirmed the public AlarmClient class
exposes zero public events. The PR A.5 design that AlarmClientConsumer
is built on (managed-event surface, no message pump) does not hold
against this assembly.

The actual notification mechanism is WM_APP messaging:
RegisterConsumer(hWnd, ...) takes a window handle because AVEVA's alarm
provider WM_APP-pokes the registered window, then GetStatistics +
GetAlarmExtendedRec pull the change set on each poke.

Practical impact:

- AlarmClientConsumer.AlarmRecordReceived has no production caller.
  RaiseAlarmRecordReceived is invoked only from tests. Subscribe(...)
  returns OK from RegisterConsumer + Subscribe but no notifications
  reach the consumer at runtime because no window is attached.
- Until A.2 lands a hidden message-only window + WindowProc that routes
  WM_APP into MxAccessAlarmEventSink.EnqueueTransition, the gateway's
  MX_EVENT_FAMILY_ON_ALARM_TRANSITION family cannot carry events.
- AcknowledgeByGuid and SnapshotActiveAlarms are pull-style and remain
  correct as written.

Changes:

- docs/AlarmClientDiscovery.md (new) — reflection probe summary, full
  AlarmClient method list, open questions for A.2 implementation.
- AlarmClientConsumer.cs xmldoc — replaced the inaccurate "managed
  event surface" claim with the WM_APP finding; flagged
  AlarmRecordReceived as unreachable in production until the WM_APP
  pump lands.
- MxAccessAlarmEventSink.cs xmldoc — replaced the "verify on dev rig"
  hedge in the wiring plan with the resolved finding; expanded the
  open-questions list (WM_APP message ID, wParam/lParam semantics, STA
  affinity, subscription scope) so the next A.2 PR knows what the
  dev-rig probe needs to answer.

Code-only no-op for the worker; worker builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 06:50:57 -04:00
parent a739fadb5f
commit 6e356da092
3 changed files with 194 additions and 33 deletions
+121
View File
@@ -0,0 +1,121 @@
# 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.
## Implications for A.2
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:
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.
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.
@@ -13,22 +13,39 @@ namespace MxGateway.Worker.MxAccess;
/// </summary>
/// <remarks>
/// <para>
/// The AVEVA alarm-manager surface (<c>IAlarmMgrDataProvider</c>)
/// exposes the events we need as plain .NET events — no Windows
/// message pump required. The worker keeps its STA thread for
/// MxAccess COM but the alarm-client callbacks arrive on the
/// AVEVA managed-client's internal callback thread.
/// <strong>⚠ Architecture finding (2026-05-01 reflection probe —
/// see <c>docs/AlarmClientDiscovery.md</c>):</strong> contrary to the
/// original PR A.5 design, <c>aaAlarmManagedClient.AlarmClient</c>
/// exposes <em>zero</em> public events on the deployed assembly
/// (<c>aaAlarmManagedClient.dll</c> v1.0.7368.41290). There is no
/// managed event surface. <c>RegisterConsumer(hWnd, …)</c> takes a
/// window handle because the actual notification mechanism is
/// WM_APP-pump messaging — AVEVA's alarm provider WM_APP-pokes the
/// registered window, and the consumer pulls the change set via
/// <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each poke.
/// </para>
/// <para>
/// The constructor parameters that <see cref="AlarmClient.RegisterConsumer"/>
/// takes (<c>hWnd</c>, product / application / version names,
/// retain-hidden flag) are pinned to safe defaults; the live
/// <c>hWnd</c> is intentionally <c>IntPtr.Zero</c> because we use
/// the managed-event surface, not the WM_APP pump. <strong>Verify
/// on dev rig</strong> that <c>RegisterConsumer</c> with
/// <c>hWnd=0</c> still wires the managed event handlers; if it
/// requires a real hWnd, the worker creates a hidden message-only
/// window and passes that handle here.
/// As a result, <see cref="AlarmRecordReceived"/> has no production
/// caller — <see cref="RaiseAlarmRecordReceived"/> is invoked only
/// from tests. <see cref="Subscribe"/> currently calls
/// <c>RegisterConsumer(hWnd: 0, …)</c> + <c>Subscribe(…)</c> and
/// returns OK, but no notifications will arrive at runtime because
/// no window is attached. Until A.2's WM_APP pump lands, the
/// gateway's <c>MX_EVENT_FAMILY_ON_ALARM_TRANSITION</c> family
/// cannot carry any events.
/// </para>
/// <para>
/// <see cref="Subscribe"/> as written is still a load-bearing call
/// the WM_APP path needs (it tells the alarm provider which
/// subscription expression to scope notifications to). The wiring
/// that has to change is the notification-receive side: replace the
/// <c>hWnd: 0</c> default with a real hidden message-only window
/// hWnd owned by the worker's STA, and add a <c>WindowProc</c> that
/// routes the AVEVA WM_APP message into a change-pull path that
/// ultimately invokes <see cref="RaiseAlarmRecordReceived"/>.
/// <see cref="AcknowledgeByGuid"/> and <see cref="SnapshotActiveAlarms"/>
/// are pull-style and don't depend on the event surface — they're
/// correct as is.
/// </para>
/// </remarks>
public sealed class AlarmClientConsumer : IMxAccessAlarmConsumer
@@ -63,10 +80,12 @@ public sealed class AlarmClientConsumer : IMxAccessAlarmConsumer
lock (subscribeLock)
{
// hWnd=0: AVEVA's managed event surface routes through the
// GetAlarmChangesCompleted .NET event, not a window-message pump.
// Verify on dev rig that 0 is accepted; if not, supply a hidden
// message-only window's handle here.
// hWnd=0: placeholder — the AVEVA alarm provider notifies via
// WM_APP messages, not via a managed-client event surface (see
// docs/AlarmClientDiscovery.md, 2026-05-01 reflection probe).
// RegisterConsumer accepts hWnd=0 but no notifications will reach
// this consumer until the WM_APP pump lands as part of A.2 and
// a real message-only window's handle is supplied here.
int registerResult = client.RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
@@ -11,19 +11,35 @@ namespace MxGateway.Worker.MxAccess;
/// </summary>
/// <remarks>
/// <para>
/// <strong>Architecture (pinned 2026-04-30):</strong> the worker hosts
/// <strong>Architecture (revised 2026-05-01 — see
/// <c>docs/AlarmClientDiscovery.md</c>):</strong> the worker hosts
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET Framework
/// 4.8 — the worker's existing runtime — and both use the same Windows
/// STA + WM_APP message pump. The MxAccess COM Toolkit at
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET
/// Framework 4.8. The MxAccess COM Toolkit at
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
/// exposes no alarm events; the alarm provider lives in a separate
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to.
/// </para>
/// <para>
/// <strong>Notification mechanism: WM_APP pump.</strong> A reflection
/// probe of <c>aaAlarmManagedClient.dll</c> (v1.0.7368.41290) on
/// 2026-05-01 confirmed the public <c>AlarmClient</c> class has zero
/// public events. The original PR A.5 design (managed-event surface,
/// no message pump) is incorrect against this assembly. AVEVA's
/// alarm provider WM_APP-pokes a window registered through
/// <c>RegisterConsumer(hWnd, …)</c>; the consumer pulls the change
/// set via <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each
/// poke. PR A.5's <see cref="AlarmClientConsumer"/> still owns the
/// <see cref="AlarmClient"/> handle and the
/// <see cref="AlarmClient.Subscribe"/> /
/// <see cref="AlarmClient.AlarmAckByGUID"/> pull-style calls; only
/// the receive path is wrong.
/// </para>
/// <para>
/// <strong>Discovered API surface</strong> (see
/// <c>AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface</c> in
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe):
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe; full
/// output captured in <c>docs/AlarmClientDiscovery.md</c>):
/// </para>
/// <list type="bullet">
/// <item><description><c>RegisterConsumer(hWnd, productName, applicationName, version, retainHidden)</c> — registers a Windows-message-pump consumer; the AVEVA alarm service WM_APP-pokes the hWnd when alarms change.</description></item>
@@ -33,20 +49,25 @@ namespace MxGateway.Worker.MxAccess;
/// <item><description><c>AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode, oprDomain, oprFullName)</c> — full-fidelity native Acknowledge: comment + four operator-identity fields are atomic with the ack transition.</description></item>
/// </list>
/// <para>
/// <strong>Wiring plan (subsequent PRs):</strong>
/// <strong>Open questions before A.2 implementation</strong>
/// (see <c>docs/AlarmClientDiscovery.md</c> "Implications for A.2"):
/// </para>
/// <list type="number">
/// <item><description>Worker session-startup wires <c>AlarmClient.RegisterConsumer</c> against the worker's existing STA hWnd; <c>Subscribe</c> with the Galaxy provider name + a permissive priority/filter range.</description></item>
/// <item><description>The STA's WM_APP handler routes alarm-changed messages into <see cref="EnqueueTransition"/>; the message ID is established at runtime via the consumer's reported handler (verify on dev rig).</description></item>
/// <item><description>Gateway-side <c>AcknowledgeAlarm</c> RPC translates to a worker command that calls <c>AlarmClient.AlarmAckByGUID</c> with the OPC UA operator's resolved identity — replaces the worker-pending diagnostic from PR A.3.</description></item>
/// <item><description>WM_APP message ID — not in the public surface, needs AVEVA C++ Toolkit reference or a runtime probe.</description></item>
/// <item><description><c>wParam</c> / <c>lParam</c> semantics — likely none (the pattern is "got poked → pull state via <c>GetStatistics</c>"), but confirm during the probe.</description></item>
/// <item><description>STA / threading affinity for the message-only window — likely the worker's existing STA, but if AVEVA assumes UI-thread inside <c>GetStatistics</c> the alarm path may need its own STA.</description></item>
/// <item><description>Subscription scope — reuse the configured Galaxy name from the data session.</description></item>
/// </list>
/// <para>
/// Until those PRs land, <see cref="Attach"/> is a no-op. The worker
/// continues to function for data subscriptions, and the gateway's
/// <see cref="MxEventFamily.OnAlarmTransition"/> family is reserved on
/// the wire but never emitted. lmxopcua-side <c>AlarmConditionService</c>
/// keeps the sub-attribute synthesis active and continues to surface
/// alarms to OPC UA Part 9 clients in the meantime.
/// Until A.2 lands a hidden message-only window + WindowProc that
/// routes WM_APP into <see cref="EnqueueTransition"/>,
/// <see cref="Attach"/> is a no-op. The worker continues to function
/// for data subscriptions, and the gateway's
/// <see cref="MxEventFamily.OnAlarmTransition"/> family is reserved
/// on the wire but never emitted. lmxopcua-side
/// <c>AlarmConditionService</c> keeps the sub-attribute synthesis
/// active and continues to surface alarms to OPC UA Part 9 clients
/// in the meantime.
/// </para>
/// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink