From 6e356da0927e6d15dada9cb29f2d1e3ed41e5a76 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 1 May 2026 06:50:57 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20AlarmClient=20public=20surface=20?= =?UTF-8?q?=E2=80=94=20managed-event=20premise=20wrong,=20WM=5FAPP=20requi?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/AlarmClientDiscovery.md | 121 ++++++++++++++++++ .../MxAccess/AlarmClientConsumer.cs | 55 +++++--- .../MxAccess/MxAccessAlarmEventSink.cs | 51 +++++--- 3 files changed, 194 insertions(+), 33 deletions(-) create mode 100644 docs/AlarmClientDiscovery.md diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md new file mode 100644 index 0000000..ef0c95a --- /dev/null +++ b/docs/AlarmClientDiscovery.md @@ -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. diff --git a/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs b/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs index a78849e..ae878fa 100644 --- a/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs +++ b/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs @@ -13,22 +13,39 @@ namespace MxGateway.Worker.MxAccess; /// /// /// -/// The AVEVA alarm-manager surface (IAlarmMgrDataProvider) -/// 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. +/// ⚠ Architecture finding (2026-05-01 reflection probe — +/// see docs/AlarmClientDiscovery.md): contrary to the +/// original PR A.5 design, aaAlarmManagedClient.AlarmClient +/// exposes zero public events on the deployed assembly +/// (aaAlarmManagedClient.dll v1.0.7368.41290). There is no +/// managed event surface. RegisterConsumer(hWnd, …) 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 +/// GetStatistics + GetAlarmExtendedRec on each poke. /// /// -/// The constructor parameters that -/// takes (hWnd, product / application / version names, -/// retain-hidden flag) are pinned to safe defaults; the live -/// hWnd is intentionally IntPtr.Zero because we use -/// the managed-event surface, not the WM_APP pump. Verify -/// on dev rig that RegisterConsumer with -/// hWnd=0 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, has no production +/// caller — is invoked only +/// from tests. currently calls +/// RegisterConsumer(hWnd: 0, …) + Subscribe(…) 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 MX_EVENT_FAMILY_ON_ALARM_TRANSITION family +/// cannot carry any events. +/// +/// +/// 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 +/// hWnd: 0 default with a real hidden message-only window +/// hWnd owned by the worker's STA, and add a WindowProc that +/// routes the AVEVA WM_APP message into a change-pull path that +/// ultimately invokes . +/// and +/// are pull-style and don't depend on the event surface — they're +/// correct as is. /// /// 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, diff --git a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs index 5b1f430..450067a 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -11,19 +11,35 @@ namespace MxGateway.Worker.MxAccess; /// /// /// -/// Architecture (pinned 2026-04-30): the worker hosts +/// Architecture (revised 2026-05-01 — see +/// docs/AlarmClientDiscovery.md): the worker hosts /// aaAlarmManagedClient.AlarmClient alongside the existing -/// ArchestrA.MxAccess 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 +/// ArchestrA.MxAccess COM consumer. Both are x86 .NET +/// Framework 4.8. The MxAccess COM Toolkit at /// C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll /// exposes no alarm events; the alarm provider lives in a separate /// AVEVA service that aaAlarmManagedClient subscribes to. /// /// +/// Notification mechanism: WM_APP pump. A reflection +/// probe of aaAlarmManagedClient.dll (v1.0.7368.41290) on +/// 2026-05-01 confirmed the public AlarmClient 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 +/// RegisterConsumer(hWnd, …); the consumer pulls the change +/// set via GetStatistics + GetAlarmExtendedRec on each +/// poke. PR A.5's still owns the +/// handle and the +/// / +/// pull-style calls; only +/// the receive path is wrong. +/// +/// /// Discovered API surface (see /// AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface in -/// MxGateway.Worker.Tests — Skip-gated reflection probe): +/// MxGateway.Worker.Tests — Skip-gated reflection probe; full +/// output captured in docs/AlarmClientDiscovery.md): /// /// /// RegisterConsumer(hWnd, productName, applicationName, version, retainHidden) — registers a Windows-message-pump consumer; the AVEVA alarm service WM_APP-pokes the hWnd when alarms change. @@ -33,20 +49,25 @@ namespace MxGateway.Worker.MxAccess; /// AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode, oprDomain, oprFullName) — full-fidelity native Acknowledge: comment + four operator-identity fields are atomic with the ack transition. /// /// -/// Wiring plan (subsequent PRs): +/// Open questions before A.2 implementation +/// (see docs/AlarmClientDiscovery.md "Implications for A.2"): /// /// -/// Worker session-startup wires AlarmClient.RegisterConsumer against the worker's existing STA hWnd; Subscribe with the Galaxy provider name + a permissive priority/filter range. -/// The STA's WM_APP handler routes alarm-changed messages into ; the message ID is established at runtime via the consumer's reported handler (verify on dev rig). -/// Gateway-side AcknowledgeAlarm RPC translates to a worker command that calls AlarmClient.AlarmAckByGUID with the OPC UA operator's resolved identity — replaces the worker-pending diagnostic from PR A.3. +/// WM_APP message ID — not in the public surface, needs AVEVA C++ Toolkit reference or a runtime probe. +/// wParam / lParam semantics — likely none (the pattern is "got poked → pull state via GetStatistics"), but confirm during the probe. +/// STA / threading affinity for the message-only window — likely the worker's existing STA, but if AVEVA assumes UI-thread inside GetStatistics the alarm path may need its own STA. +/// Subscription scope — reuse the configured Galaxy name from the data session. /// /// -/// Until those PRs land, is a no-op. The worker -/// continues to function for data subscriptions, and the gateway's -/// family is reserved on -/// the wire but never emitted. lmxopcua-side AlarmConditionService -/// 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 , +/// is a no-op. The worker continues to function +/// for data subscriptions, and the gateway's +/// family is reserved +/// on the wire but never emitted. lmxopcua-side +/// AlarmConditionService keeps the sub-attribute synthesis +/// active and continues to surface alarms to OPC UA Part 9 clients +/// in the meantime. /// /// public sealed class MxAccessAlarmEventSink : IMxAccessEventSink