A.2: replace AlarmClientConsumer with wnwrap-based polling consumer

Switch the worker's alarm-consumer surface from `aaAlarmManagedClient.AlarmClient`
to `WNWRAPCONSUMERLib.wwAlarmConsumerClass` (CLSID 7AB52E5F-…) hosted by
`wnwrapConsumer.dll`. The new path returns alarm records as a BSTR XML
payload via `GetXmlCurrentAlarms2`, bypassing the FILETIME→DateTime
auto-marshaling that crashed `GetHighPriAlarm` with
ArgumentOutOfRangeException on every poll. Live captured 60/60 polls
clean against `\DESKTOP-6JL3KKO\Galaxy!DEV` while a System Platform
script flipped TestMachine_001.TestAlarm001 every 10s; the GUID,
priority, state (UNACK_ALM ↔ UNACK_RTN), and ASCII-formatted timestamps
arrived end-to-end.

Implementation:
- `Interop.WNWRAPCONSUMERLib.dll` generated via tlbimp, checked in under
  `lib/` so dev boxes don't need the SDK to build.
- New `WnWrapAlarmConsumer` (replaces `AlarmClientConsumer`): owns a
  500ms polling timer, parses `GetXmlCurrentAlarms2` output, diffs the
  snapshot keyed by alarm GUID, and raises one
  `MxAlarmTransitionEvent` per state change. Includes the
  Initialize→Register-before-Subscribe ordering fix found during
  Discovery probe runs.
- New library-agnostic types `MxAlarmSnapshotRecord` /
  `MxAlarmStateKind` / `MxAlarmTransitionEvent` so the proto-build
  path is testable without an AVEVA install.
- `AlarmRecordTransitionMapper` retired the COM-coupled
  `MapTransitionKind(eAlmTransitions)`; new pure helpers
  `ParseStateKind`, `MapTransition(prev, curr)`, and
  `ParseTransitionTimestampUtc` cover XML decode + state-delta logic.
- `IMxAccessAlarmConsumer` event surface changed from
  `EventHandler<AlarmRecord>` to `EventHandler<MxAlarmTransitionEvent>`
  and `SnapshotActiveAlarms()` returns `MxAlarmSnapshotRecord` —
  decoupling the interface from any specific COM library.
- Worker csproj drops `aaAlarmManagedClient` / `IAlarmMgrDataProvider`
  refs; adds `Interop.WNWRAPCONSUMERLib`.

Tests:
- 36 new unit tests (state-string mapping, prev/current → proto kind
  decision table, timestamp UTC reassembly, XML payload parser, 32-char
  hex GUID round-trip) covering everything that doesn't touch the live
  COM surface — all passing.
- Skip-gated `WnWrapConsumerProbeTests.ProbeWnWrapConsumer` archives
  the live capture flow for regression / future probes.

Docs:
- `docs/AlarmClientDiscovery.md` "Option A — captured" section records
  sample XML payloads, the mangled `SetXmlAlarmQuery` round-trip
  (prefer `Subscribe` for filtering), the `GetStatistics`
  AccessViolationException quirk, and the worker-integration outline.

Pre-existing failure noted (separate):
`MxAccessInteropReference_ExistsOnlyInWorkerProject` was already
failing on HEAD — the test project still references `ArchestrA.MxAccess`
for the Skip-gated discovery probes. Not regressed by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 09:44:15 -04:00
parent f490ae2593
commit f711a55be4
13 changed files with 1326 additions and 318 deletions
@@ -4,70 +4,21 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// Sink for native MxAccess alarm transitions. Bridges
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// </summary>
/// <remarks>
/// <para>
/// <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 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; 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>
/// <item><description><c>Subscribe(provider, fromPri, toPri, queryType, sortFlags, filterMask, filterSpec)</c> — subscribes to a Galaxy alarm provider with priority + filter scoping.</description></item>
/// <item><description><c>GetStatistics(out percentQuery, totalAlarms, activeAlarms, …, out int[] changeCodes, out int[] changePos, out int[] hAlarm)</c> — called on each WM_APP poke; enumerates which alarms changed.</description></item>
/// <item><description><c>GetAlarmExtendedRec(index, out AlarmRecord)</c> — pulls the full alarm record (operator, comment, original raise, category, severity).</description></item>
/// <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>Open questions before A.2 implementation</strong>
/// (see <c>docs/AlarmClientDiscovery.md</c> "Implications for A.2"):
/// </para>
/// <list type="number">
/// <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 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.
/// The dispatcher subscribes the consumer's
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
/// to <see cref="EnqueueTransition"/> at session attach time. The
/// <see cref="Attach"/> override here is a stub kept for the data-
/// session shape; the actual wire-up between consumer and sink
/// lives in the A.3 dispatcher (one step up the stack). Captured
/// payload schema and consumer threading discipline are described in
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
/// </para>
/// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink