Compare commits

...

17 Commits

Author SHA1 Message Date
dohertj2 867bf18116 alarms-over-gateway: full pipeline (#118)
Seven slices on this branch implement the full alarms-over-gateway path:

1. f711a55  A.2: WnWrapAlarmConsumer replaces aaAlarmManagedClient (wnwrapConsumer.dll, XML payload bypasses FILETIME crash)
2. 82eb0ad  A.3 in-process: AlarmDispatcher wires consumer events onto worker MxAccessEventQueue
3. 01f5e6a  A.3 worker IPC: SubscribeAlarms / UnsubscribeAlarms / AcknowledgeAlarm / QueryActiveAlarms commands + executor switch arms
4. 9b21ca3  A.3 gateway: WorkerAlarmRpcDispatcher routes RPCs through the IPC; replaces NotWiredAlarmRpcDispatcher in DI
5. 47b1fd4  A.3 auto-subscribe: SessionManager issues SubscribeAlarms on session open (gated by Alarms.Enabled config)
6. 4e02927  A.3 alarm-ack-by-name: public AcknowledgeAlarm now accepts Provider!Group.Tag references via AlarmAckByName
7. a4ed605  A.3 live smoke: end-to-end pipeline verified on dev rig; surfaced + fixed three production-relevant AVEVA quirks (SetXmlAlarmQuery required for reads, breaks acks; v2 8-arg AlarmAckByName is a stub; AlarmAckByGUID is a stub)

Known follow-ups not in scope:
 - WnWrapAlarmConsumer.PollOnce needs to be driven from the worker StaRuntime (production hosting); currently the timer-based path deadlocks on cross-apartment marshaling without an STA pump.
 - Pre-existing structure-test failure (test project ArchestrA.MxAccess ref) untouched.

Test counts at merge time:
  Worker: 195 pass / 4 skipped (live probes incl. AlarmsLiveSmokeTests) / 1 pre-existing fail
  Server: 308 pass / 0 fail
2026-05-01 12:31:27 -04:00
Joseph Doherty a4ed605f74 A.3 (live smoke): full alarms-over-gateway pipeline verified end-to-end
Skip-gated AlarmsLiveSmokeTests.Alarms_full_pipeline_round_trip ran
against the dev rig with the flip script firing
TestMachine_001.TestAlarm001 every 10s. Verified:
  - Subscribe + 1st PollOnce yield real transition events
  - Field-by-field decode correct (provider, group, tag, severity,
    UTC timestamp, comment, type)
  - SnapshotActiveAlarms reflects current state
  - AcknowledgeByName(real identity) -> rc=0
  - Pipeline keeps streaming transitions on the 10s cadence post-ack

Three production quirks surfaced and were fixed in
WnWrapAlarmConsumer:

1. SetXmlAlarmQuery is mandatory for reads. Skipping it (per the
   earlier discovery-doc recommendation) makes the first
   GetXmlCurrentAlarms2 fail with E_FAIL. The doc's claim that the
   call is unnecessary because the round-trip echo is mangled was
   wrong — mangled echo or not, the call is required.

2. SetXmlAlarmQuery breaks AlarmAckByName on the same consumer
   instance (returns -55). Workaround: provision a parallel
   "ack-only" wnwrap consumer that runs Initialize → Register →
   Subscribe via the v1-prefixed methods, no SetXmlAlarmQuery.
   Production WnWrapAlarmConsumer now holds two COM clients;
   AcknowledgeByName always dispatches through the ack-only one.

3. AlarmAckByName has v2 (8-arg) and v1 (6-arg) overloads. The v2
   8-arg overload returns -55 on this AVEVA build (apparently a
   stub); the v1 6-arg overload works. Production now calls the
   6-arg overload, discarding the proto's operator_domain and
   operator_full_name fields. The proto contract keeps both for
   forward-compat if AVEVA fixes the v2 method.

Bonus finding (not fixed here): AlarmAckByGUID throws
NotImplementedException on wnwrap. Reference→GUID lookup that we
initially planned to plumb is therefore not viable; all acks must
go through AlarmAckByName. WorkerAlarmRpcDispatcher.AcknowledgeAsync
already routes references through the by-name path, so this only
affects the GUID-input branch (which the worker tries first if the
input parses as a GUID — that branch will surface
NotImplementedException as MxaccessFailure if a client supplies one).

Threading caveat: wnwrap is ThreadingModel=Apartment, so the
consumer's internal Timer (firing on threadpool threads) blocks on
cross-apartment marshaling without an STA message pump. The smoke
test sidesteps this with pollIntervalMilliseconds=0 (Timer disabled)
+ manual PollOnce calls from the test STA. Production hosting will
route polls through the worker's StaRuntime in a follow-up; PollOnce
is now public so the wire-up is straightforward.

Test counts after this slice:
  Worker: 195 pass / 4 skipped (live probes incl. new live smoke) /
          1 pre-existing structure-fail (untouched)
  Server: 308 pass / 0 fail
Solution builds clean.

docs/AlarmClientDiscovery.md "Live smoke-test discoveries" section
records all five findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:17:39 -04:00
Joseph Doherty 4e02927f01 A.3 (alarm-ack-by-name): public AcknowledgeAlarm now accepts Provider!Group.Tag references
Closes the gap where the public AcknowledgeAlarm RPC required canonical
GUIDs but OnAlarmTransitionEvent.AlarmFullReference is "Provider!Group.Tag".
Adds an AVEVA AlarmAckByName path that wraps wwAlarmConsumerClass.AlarmAckByName
so callers can ack with the natural reference.

Proto:
- New MxCommandKind.AcknowledgeAlarmByName (=29).
- New AcknowledgeAlarmByNameCommand(alarm_name, provider_name, group_name,
  comment, operator_user/node/domain/full_name) on MxCommand oneof.
- AcknowledgeAlarmReplyPayload (existing) carries the AVEVA native
  status; reused for the by-name path.

Worker:
- IMxAccessAlarmConsumer + WnWrapAlarmConsumer + AlarmDispatcher +
  AlarmCommandHandler all gain an AcknowledgeByName(name, provider,
  group, comment, operator-identity) overload that maps to
  wwAlarmConsumerClass.AlarmAckByName.
- MxAccessCommandExecutor: new switch arm routes
  MxCommandKind.AcknowledgeAlarmByName to the handler. Empty alarm_name
  yields InvalidRequest; handler exceptions surface as MxaccessFailure.

Gateway:
- WorkerAlarmRpcDispatcher.TryParseAlarmReference: parses
  "Provider!Group.Tag" with the convention that the FIRST '!' separates
  provider, the FIRST '.' after '!' separates group; tag may contain
  more dots.
- AcknowledgeAsync now branches: GUID input → AcknowledgeAlarm command
  (existing path); reference input → AcknowledgeAlarmByName command
  (new path); neither parses → InvalidRequest with a clear diagnostic.

Tests: 13 new unit tests cover each layer end-to-end:
- WorkerAlarmRpcDispatcher.TryParseAlarmReference (3 valid + 8 invalid
  forms) including the realistic 4-component "Galaxy!TestArea.
  TestMachine_001.TestAlarm001" reference.
- WorkerAlarmRpcDispatcher.AcknowledgeAsync routes references through
  AcknowledgeAlarmByName + propagates the full operator tuple.
- Executor switch arm carries the by-name tuple and rejects empty
  alarm_name.
- AlarmDispatcher.AcknowledgeByName forwards to consumer.
- Existing fakes extended for the new overload.

Counts: server 308/0, worker 195/3 skip / 1 pre-existing structure-fail
(untouched). Solution builds clean.

End-to-end alarms-over-gateway now serves the full lmxopcua flow:
client.AcknowledgeAlarm(reference="Galaxy!TestArea.TestMachine_001.TestAlarm001",
operator_user="alice") → gateway parses → IPC AcknowledgeAlarmByName →
worker AlarmAckByName → AVEVA history. The remaining piece for full
parity is a live dev-rig smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:17:15 -04:00
Joseph Doherty 47b1fd422c A.3 (auto-subscribe): SessionManager issues SubscribeAlarms on session open
Adds the missing trigger that activates the worker's wnwrap consumer.
Without this, every session opened in OK state but the consumer never
started, so AcknowledgeAlarm/QueryActiveAlarms returned "alarm consumer
not configured" forever.

New AlarmsOptions config block (under MxGateway:Alarms):
  - Enabled (default false): gates the auto-subscribe path so existing
    deployments without alarm configuration are unaffected.
  - SubscriptionExpression: explicit AVEVA expression like
    \<machine>\Galaxy!<area>.
  - DefaultArea: fallback used when SubscriptionExpression is empty;
    composes \$(MachineName)\Galaxy!$(DefaultArea).
  - RequireSubscribeOnOpen (default false): when true, an auto-subscribe
    failure faults the session; when false, the failure is logged and
    the session stays Ready (data subscriptions keep working, alarms
    return "not subscribed" until the operator retries).

SessionManager.OpenSessionAsync gains a TryAutoSubscribeAlarmsAsync hook
that runs after MarkReady. Skips when alarms are disabled; otherwise
builds a SubscribeAlarmsCommand, invokes it on the session's worker
client, and either logs the resulting status or escalates per
RequireSubscribeOnOpen. SessionManagerException is the failure mode for
the strict path so callers in MxAccessGatewayService surface it as
session-open-failed.

Tests: 7 new unit tests cover the disabled lane, expression-driven
subscribe, DefaultArea fallback, success path, soft-failure (require
off), strict-failure (require on), and missing-config-strict-throw.
Server suite total: 295 pass / 0 fail. Solution builds clean.

End-to-end alarms-over-gateway path is now live (with config). Open a
session against a gateway with Alarms.Enabled=true + a valid
SubscriptionExpression; the worker's wnwrap consumer auto-subscribes;
QueryActiveAlarms streams snapshots; AcknowledgeAlarm acks by GUID.
Reference→GUID resolution (AlarmAckByName worker command) and the live
dev-rig smoke test remain follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:10:13 -04:00
Joseph Doherty 9b21ca3554 A.3 (gateway dispatcher): WorkerAlarmRpcDispatcher routes alarm RPCs over the worker pipe
Replaces NotWiredAlarmRpcDispatcher in DI with a production
implementation that issues the new MxCommandKind.{AcknowledgeAlarm,
QueryActiveAlarms} commands across the IPC and unwraps the resulting
MxCommandReply into the public RPC types.

QueryActiveAlarms is fully wired: builds the QueryActiveAlarmsCommand
(forwarding alarm_filter_prefix), invokes it on the resolved
GatewaySession's worker client, and yields each ActiveAlarmSnapshot
from the QueryActiveAlarmsReplyPayload as the RPC stream. Worker
failures + missing sessions yield an empty stream — matches the
ConditionRefresh contract clients already speak to.

AcknowledgeAlarm is partially wired: the public RPC takes
AlarmFullReference (Provider!Group.Tag), but the worker's wnwrap
consumer acks by GUID. Strategy:
- If AlarmFullReference parses as a canonical GUID, forward it
  directly through MxCommandKind.AcknowledgeAlarm. Native status
  flows back via MxCommandReply.Hresult and the dedicated
  AcknowledgeAlarmReplyPayload.NativeStatus.
- Otherwise, return InvalidRequest with a clear diagnostic naming the
  follow-up — reference→GUID lookup needs a worker-side AlarmAckByName
  command wrapping wwAlarmConsumerClass.AlarmAckByName.

DI: SessionServiceCollectionExtensions registers WorkerAlarmRpcDispatcher
as the default IAlarmRpcDispatcher; MxAccessGatewayService picks it up
via constructor injection. NotWiredAlarmRpcDispatcher is retained for
test fixtures that want the no-side-effect fake.

Tests: 7 new unit tests cover session-not-found short-circuit, GUID-vs-
reference branching, native-status propagation, worker MxaccessFailure
diagnostic propagation, and snapshot-stream yielding. Server test
suite total: 288/0 fail. Solution builds clean.

End-to-end alarms-over-gateway pipeline status:
  consumer → sink → queue (A.2 + A.3 in-process slice)
  worker IPC commands (A.3 worker slice)
  gateway dispatcher (this slice)

Remaining for full E2E:
  - Auto-issue SubscribeAlarms on session open (or add a public
    SubscribeAlarms RPC). Without this trigger the consumer never
    starts and Acknowledge/Query return "not subscribed".
  - AlarmAckByName worker command for ack-by-reference.
  - End-to-end live test against the dev rig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:58:40 -04:00
Joseph Doherty 01f5e6ad91 A.3 (worker IPC slice): proto SubscribeAlarms/Acknowledge/QueryActive commands + executor routing
Adds the worker-side IPC surface for the alarm subsystem so the gateway
can drive the AlarmDispatcher across the named-pipe boundary. Adds four
proto MxCommandKind values + matching command messages and two
MxCommandReply payload variants:

- SubscribeAlarmsCommand(subscription_expression)
- UnsubscribeAlarmsCommand
- AcknowledgeAlarmCommand(alarm_guid, comment, operator_user/node/domain/full_name)
- QueryActiveAlarmsCommand(alarm_filter_prefix)
- AcknowledgeAlarmReplyPayload(native_status)
- QueryActiveAlarmsReplyPayload(repeated ActiveAlarmSnapshot snapshots)

Worker plumbing:

- New IAlarmCommandHandler interface + AlarmCommandHandler production
  impl. Lazy-creates an AlarmDispatcher (with a wnwrap-backed consumer
  by default) on the first SubscribeAlarms; routes Acknowledge / QueryActive /
  Unsubscribe through it. Idempotent under repeated Unsubscribe; rejects
  a second Subscribe without an intervening Unsubscribe; cleans up the
  consumer if the underlying Subscribe call throws.
- MxAccessCommandExecutor: 4 new switch arms map MxCommandKind values to
  IAlarmCommandHandler calls. Acknowledge surfaces the AVEVA native
  status into both MxCommandReply.Hresult and the dedicated
  AcknowledgeAlarmReplyPayload.NativeStatus so gateway-side consumers
  can echo it without unpacking the outer envelope. Invalid GUIDs and
  missing payloads return InvalidRequest; handler exceptions return
  MxaccessFailure with the exception message in DiagnosticMessage.
- MxAccessStaSession: new constructor overload accepts an
  alarmCommandHandlerFactory; it's invoked on the STA thread during
  StartAsync and the resulting handler is passed into the executor.
  ShutdownGracefullyAsync + Dispose tear it down on the STA before the
  data-side cleanup runs.

Tests: 20 new unit tests covering AlarmCommandHandler lazy lifecycle
(Subscribe/Unsubscribe/Acknowledge/Query/Dispose, error paths) and the
executor's 4 alarm switch arms (OK/InvalidRequest/MxaccessFailure paths,
hresult propagation, prefix filtering). Worker test suite total: 192
passed / 3 skipped (live probes) / 1 pre-existing structure-test fail
(untouched).

Deferred to next slice: gateway-side WorkerAlarmRpcDispatcher that
replaces NotWiredAlarmRpcDispatcher, builds + sends these commands across
the IPC, and unwraps the resulting MxCommandReply into AcknowledgeAlarmReply
/ ActiveAlarmSnapshot stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:52:04 -04:00
Joseph Doherty 82eb0ad569 A.3 (in-process slice): AlarmDispatcher wires consumer events onto event queue
Adds the in-process plumbing that connects WnWrapAlarmConsumer's
AlarmTransitionEmitted stream to the worker's MxAccessEventQueue via
MxAccessAlarmEventSink. With this change a transition raised by the
consumer lands as an OnAlarmTransitionEvent proto on the queue,
SessionId attached, ready for IPC dispatch.

Mapping: provider!group.tag → AlarmFullReference, tag → SourceObjectReference,
priority → severity, wnwrap STATE → AlarmConditionState (Active /
ActiveAcked / Inactive — wnwrap's ack-vs-unack-on-cleared distinction
collapses since OPC UA Part 9 doesn't model it). State delta drives
AlarmTransitionKind via the existing AlarmRecordTransitionMapper table.

Holding off on the proto IPC additions (SubscribeAlarms /
AcknowledgeAlarm / QueryActiveAlarms commands + WorkerAlarmRpcDispatcher)
for a follow-up — those touch every layer of the worker IPC and warrant
their own PR. This slice proves the consumer→sink→queue pipeline
end-to-end with unit tests and clears the path for the proto additions
to plug in cleanly.

Tests: 10 new unit tests cover field-by-field mapping, the
"unchanged-state-doesn't-emit" filter, the state→transition kind table,
Subscribe / Acknowledge passthrough, SnapshotActiveAlarms → proto
ActiveAlarmSnapshot mapping, and Dispose detaches the handler. All
passing; total worker test count 172/3 skip / 1 pre-existing structure
fail (untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:52:35 -04:00
Joseph Doherty f711a55be4 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>
2026-05-01 09:44:15 -04:00
Joseph Doherty f490ae2593 docs: revise interop fix path — wnwrapConsumer.dll is the right surface
Reflection on aaAlarmManagedClient.AlarmClient shows it implements
only IDisposable (no [ComImport] interface, no class GUID) and
has a single field "CwwAlarmConsumer* m_almUnmanaged". So
AlarmClient is a C++/CLI managed wrapper around a native C++
class -- NOT a COM-interop class. The DateTime conversion happens
INSIDE AVEVA's wrapper IL, not at the .NET-COM marshaling
boundary. There's no separate COM interface to QI to.

Revised approach (in docs/AlarmClientDiscovery.md):

A. wnwrapConsumer.dll -- separate standalone COM library AVEVA
   ships at "C:\Program Files (x86)\Common Files\ArchestrA"
   exposing WNWRAPCONSUMERLib.wwAlarmConsumerClass with
   SetXmlAlarmQuery / GetXmlCurrentAlarms. XML-string output
   bypasses FILETIME marshaling entirely. Best fit -- real COM,
   self-contained, conventional production-grade approach.
B. Patch aaAlarmManagedClient.dll IL -- direct but modifies a
   vendor binary, brittle to upgrades.
C. Reflect into m_almUnmanaged and call native vtable directly
   -- requires reverse-engineering the C++ class layout.

Picking A. Probe restored to Skip; next commit starts the
wnwrapConsumer integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:15:37 -04:00
Joseph Doherty 39f9fd8946 probe: BREAKTHROUGH — alarms flow via canonical \Node\Galaxy!Area, blocked by DateTime marshaling
Two findings that turn the alarm capture path on:

1. Subscription expression: \<MachineName>\Galaxy!<Area> is the
   canonical AlarmClient subscription format per ArchestrA docs:
   \Node\Provider!Area!Filter, with Provider literally "Galaxy"
   (not the Galaxy name) and Node being the machine name. For
   this rig: \DESKTOP-6JL3KKO\Galaxy!DEV catches alarms.
2. InitializeConsumer before RegisterConsumer — discovered
   earlier; bug-fix for PR A.5's AlarmClientConsumer.

With these in place, GetHighPriAlarm returned a record on every
poll for 60s straight (117/117 calls). But every call throws
ArgumentOutOfRangeException: Not a valid Win32 FileTime, because
AlarmRecord has five DateTime fields (ar_Time / ar_OrigTime /
ar_AckTime / ar_RtnTime / ar_SubTime) and AVEVA writes sentinel
FILETIME values for unset ones (e.g., ar_AckTime on an
unacknowledged alarm). The aaAlarmManagedClient.dll auto-marshals
FILETIME -> DateTime and rejects out-of-range values.

GetStatistics still reports total=0 active=0 even with
GetHighPriAlarm returning records — those two APIs have
different views. The active read API for current alarms is
GetHighPriAlarm, not GetStatistics's change array.

So the consumer chain works. The blocking issue is now
extracting the payload past the AVEVA-shipped DateTime
auto-marshaling. Three approaches for the next PR:

1. Patch aaAlarmManagedClient.dll via ildasm/ilasm round-trip.
2. Define a custom [ComImport] interface with safe-blittable
   types and Marshal.QueryInterface to it.
3. Use IDispatch late binding to bypass strong-typed marshaling.

Option 2 is cleanest; needs the AlarmClient COM IID.

Probe changes:

- Subscription expression set to \<MachineName>\Galaxy!DEV.
- GetHighPriAlarm tally counters (ok-with-record vs throw).
- 117 throws / 0 ok-with-record over 60s confirms alarms are
  flowing continuously while the user's flip script runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:06:45 -04:00
Joseph Doherty bb7be14d1d probe: aaAlarmManagedClient receives no alarm data — full consumer chain verified
Sixth probe iteration with every consumer-side knob exhausted:

- Subscriptions tried (all rc=0): \Galaxy!, \Galaxy!*, \Galaxy!,
  \Galaxy!TestArea, \.\Galaxy!.
- Read channels polled at 500ms: GetStatistics, GetHighPriAlarm,
  SFCreateSnapshot + SFGetStatistics.
- Filters: priority 0..32767, qtSummary + qtHistory both tried,
  asAlarmActiveNow.
- AlarmRecord pre-init to FILETIME epoch to dodge marshaler bug
  on default(DateTime).

Result: every read API returns empty for the entire 60s window
even with TestMachine_001.TestAlarm001 firing every 10s and
aaObjectViewer confirming InAlarm transitions. The
aaAlarmManagedClient.AlarmClient is not the receive surface
AVEVA's alarm pipeline routes to in this Galaxy configuration.

The consumer chain is verified working end-to-end: Initialize +
Register + Subscribe all succeed, GetProviders finds the
provider, the WM 0xC275 heartbeat fires at 1Hz to AVEVA's
internal hwnd. There is simply no alarm data flowing through
this consumer surface.

Next investigation is not consumer-side: either find the SDK
aaObjectViewer's alarm panel uses, or query the historian
event storage directly. If alarms only flow via the historian
path on this customer's Galaxy, the worker's PR A.5 architecture
is a dead-end and A.2 needs a different transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:26:29 -04:00
Joseph Doherty 8ac6642bf8 probe: subscribe-parameter sweep — alarms still absent, producer-side blocked
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>
2026-05-01 07:53:26 -04:00
Joseph Doherty 4e8928cf71 probe: InitializeConsumer required — provider visible after, alarms still absent
InitializeConsumer was the missing call. Adding it before
RegisterConsumer makes the \Galaxy! provider appear in
GetProviders (status 0 -> 100 within 500ms). Without Initialize,
GetProviders returns an empty list even though everything else
returns rc=0 (success).

Probe trace 2026-05-01:

  InitializeConsumer -> 0
  RegisterConsumer -> 0
  GetProviders [after Register] -> count=0 list=[]
  Subscribe('\Galaxy!') -> 0
  GetProviders [after Subscribe] -> count=1 list=[  0 \Galaxy!]
  GetProviders [poll #1] -> count=1 list=[100 \Galaxy!]

Despite the provider being 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
TestMachine_001.TestAlarm001 every 10s during the run.

So the consumer chain works end-to-end. What's missing is alarm
traffic from the producer side. The next discriminator is
whether ObjectViewer (or another live consumer) sees the alarm
fire while the script runs.

API-ordering bug fix to apply to PR A.5's AlarmClientConsumer
regardless of how A.2 lands: AlarmClientConsumer.Subscribe
should call InitializeConsumer before RegisterConsumer (currently
omits Initialize entirely, which means the provider chain is
never visible from the worker either). That fix lifts a
fundamental bug independent of the polling-vs-callback question.

Probe changes:

- Added InitializeConsumer call before RegisterConsumer.
- Added LogProviders helper that logs only on change; called
  after Register, after Subscribe, and on every poll. Easier
  to spot when the provider chain transitions from empty to
  populated.
- Restored Skip-gating after run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:43:06 -04:00
Joseph Doherty f4423dfb6d probe: GetProviders=0 — alarm path upstream-blocked on dev rig
Extended AlarmClientWmProbeTests to call AlarmClient.GetProviders
after RegisterConsumer. Run 2026-05-01:

  GetProviders -> rc=0 count=0 list=[]

Zero alarm providers visible to the consumer. This explains every
preceding probe run — no providers means no alarm events,
regardless of subscription expression or value writes upstream.
Even with a System Platform script flipping
TestMachine_001.TestAlarm001 every 10s during the run,
GetStatistics reported no transitions, no positions[] entries,
no field changes after t=0.85s.

Possible causes (dev-rig configuration, not code):

1. No $Alarm extension on the test bool — flipping the value
   writes a value but doesn't fire an alarm.
2. AVEVA alarm-manager service (aaAlarmMgr or equivalent) not
   running on this rig.
3. Process security context — providers registered under a
   service account aren't visible to a consumer running under
   a normal user account.

A.2 implementation is blocked on this until at least one provider
is visible. Once a provider exists, the polling-vs-callback
question is answerable in one probe run; without a provider both
paths return the same "nothing happening" answer.

Probe changes:

- Added in-process MxAccess Write attempt (TriggerWriteValue) —
  hit TargetParameterCountException so the Write signature is
  not (handle, item, value); reflection diag added but not
  resolved. Now disabled in favor of external trigger.
- Added GetProviders enumeration after RegisterConsumer.
- Removed firePrint/clearPrint markers; probe is observe-only.
- Added ArchestrA.MxAccess reference to the test project.

Also updated docs/AlarmClientDiscovery.md with the
alarm-provider-visibility section explaining what's blocked
and why.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:37:15 -04:00
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
Joseph Doherty 12881ca791 docs+test: live AlarmClient WM probe — heartbeat-only, hWnd not used
Added MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs as a Skip-gated
runtime probe. Run on the dev rig 2026-05-01 against the live AVEVA
install (Galaxy reachable, no manual alarm fired). Findings:

- RegisterConsumer(hWnd, ...) and Subscribe("\Galaxy!", ...) both
  return 0 (success). Calls are valid against the deployed assembly.
- A registered-message-class WM (ID 0xC275 in this OS session) fires
  every ~1 second after Subscribe completes. Constant wParam=0x1100,
  constant lParam=0x079E46D8 — looks like 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 hWnd we registered. The
  consumer window receives only the standard WM_CREATE / WM_DESTROY
  sequence; no AVEVA traffic in between.

This invalidates the WM_APP-pump design previously documented. The
hWnd parameter to RegisterConsumer appears to be a registration
identity only — AVEVA's notification path runs entirely against
AVEVA's own internal window.

Two viable A.2 designs replace the previous one:

1. Polling. Call GetStatistics on a 500ms timer in the worker's STA
   and react to whatever change set it reports. No window plumbing
   needed. Latency floor = poll period. Matches AVEVA's own
   internal heartbeat cadence.
2. Hook AVEVA's internal window. Discover AVEVA's own hwnd,
   SetWindowSubclass on it, intercept WM 0xC275 on AVEVA's thread.
   Higher fidelity, lower latency, but invasive and fragile across
   AVEVA upgrades — likely a non-starter.

Recommendation in docs/AlarmClientDiscovery.md is option 1 (polling)
unless a follow-up probe with a real fired alarm shows AVEVA does
post change-specific WMs to a different hWnd.

Open follow-up probes documented:

- Fire a real Galaxy alarm during pump and check whether WM 0xC275
  cadence changes or GetStatistics returns non-empty arrays.
- GetStatistics threading affinity test.
- Hook AVEVA's internal window 0x18032E.
- Decompile aaAlarmManagedClient IL for RegisterConsumer to find
  whether WNAL_Register's callback surface is wrapped.

Test project changes:

- Added Reference to aaAlarmManagedClient + IAlarmMgrDataProvider
  (Private=true so the DLL gets copied into bin for test load).
- Test-suite-wide: 127 real tests still pass; both alarm-related
  Skip-gated tests skip cleanly.

Code change to the probe is additive — the worker is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 07:05:47 -04:00
Joseph Doherty 6e356da092 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>
2026-05-01 06:50:57 -04:00
31 changed files with 8640 additions and 630 deletions
+792
View File
@@ -0,0 +1,792 @@
# 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.
## 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
1. **No `$Alarm` extension on the test bool.** If
`TestMachine_001.TestAlarm001` is a regular UDA without a
`BoolAlarm` extension wired to it, flipping the value just
writes a new value — no alarm fires.
2. **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.
3. **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 where `dotnet test` doesn'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:
- `qtSummary` and `qtHistory` (the only `eQueryType` values).
- Priority 1..999 and 0..32767.
- `eAlarmFilterState.asNone` and `asAlarmActiveNow` for both
`FilterMask` and `FilterSpecification`.
`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.
## Multi-channel + multi-subscription probe — sixth run, 2026-05-01
Extended the probe to try every consumer-side approach in
parallel:
- **Subscription expressions** (sequential): `\Galaxy!`,
`\Galaxy!*`, `\\Galaxy!`, `\Galaxy!TestArea`, `\\.\Galaxy!`.
All Subscribe calls returned rc=0; the last one
(`\\.\Galaxy!`) is reflected in `GetProviders` (count=1).
- **Read channels** polled at 500ms cadence: `GetStatistics`,
`GetHighPriAlarm`, `SFCreateSnapshot` + `SFGetStatistics`.
- **Filter+sort**: priority 0..32767, `qtSummary`,
state=`asAlarmActiveNow`, sort=`sfReturnNewestFirst`.
- **AlarmRecord init** (worked around `Not a valid Win32
FileTime` exception): all DateTime fields pre-set to FILETIME
epoch (1601-01-01 UTC) before the call, since
`default(DateTime)` is outside FILETIME range and trips the
interop marshaler.
Result of the 60s run with `TestMachine_001.TestAlarm001` being
flipped every 10s:
```
Subscribe('\Galaxy!') -> 0
Subscribe('\Galaxy!*') -> 0
Subscribe('\\Galaxy!') -> 0
Subscribe('\Galaxy!TestArea') -> 0
Subscribe('\\.\Galaxy!') -> 0
GetProviders [after Subscribe-multi] -> count=1 list=[ 0 \\.\Galaxy!]
GetStatistics #1: total=0 active=0 changes=1 codes=[7] positions=[] handles=[]
GetHighPriAlarm #1: rc=0 { }
SF channel #1: SFCreate=0 numAlarms=0 SFStats=0 unackRet=0 unackAlm=0 ackAlm=0 others=0 events=0 idxNewest=-1
```
**No further "(changed)" entries for the entire 60s window.**
Every read API returned the same empty result on every poll.
User confirms the alarm IS firing — `aaObjectViewer` sees
`$Alarm.InAlarm` flip in lockstep with the script. Historian
records exist (per user — needs verification by querying the
historian directly).
## Conclusion of consumer-side probing
`aaAlarmManagedClient.AlarmClient` is **not** the receive
surface AVEVA's alarm pipeline routes to in this Galaxy
configuration. The consumer chain is verified end-to-end:
- `InitializeConsumer` + `RegisterConsumer` + `Subscribe` all
succeed (rc=0).
- `GetProviders` finds `\Galaxy!` once Initialize is called.
- All read APIs (`GetStatistics`, `GetHighPriAlarm`,
`SFCreateSnapshot`/`SFGetStatistics`) return empty even with
every documented filter combination.
- The consumer's hWnd receives zero AVEVA messages between
`WM_CREATE` and `WM_DESTROY`; AVEVA's traffic goes to its own
internal hwnd.
The next investigation directions are not consumer-side:
1. **Inspect `aaObjectViewer`'s alarm SDK** to see what library
it uses to read alarms. If different from
`aaAlarmManagedClient`, switch the worker over.
2. **Query the historian directly** (`aahEventStorage` /
`aahEventSvc`) to confirm alarms are recorded — and use the
same path for v2 alarm capture.
3. **Inspect AVEVA's alarm-routing config** for this Galaxy in
System Platform IDE — area assignments, alarm provider
bindings, "publish alarm events to" settings on the platform.
For A.2 implementation: the `aaAlarmManagedClient` path the
gateway-worker is currently architected around may be a
dead-end on customer Galaxies configured this way. If the
alarms truly only flow through the historian event-storage path,
A.2 needs to consume from `aahEventStorage` instead — a
fundamental architecture pivot.
## BREAKTHROUGH — seventh probe run, 2026-05-01
Two changes finally produced a signal:
1. **Subscription scope:** `\\<MachineName>\Galaxy!<TopArea>` is the
canonical AlarmClient subscription format (per ArchestrA Alarm
Client docs at `archestra6.rssing.com/chan-12008125/article13.html`):
`\\Node\Provider!Area!Filter`, where Node is the *machine* name,
Provider is **literally `Galaxy`**, and Area is a hosted area
object. For this rig (`\\DESKTOP-6JL3KKO\Galaxy!DEV`) the DEV
area — the platform's primary area — is the right scope. Earlier
`\Galaxy!`, `\Galaxy!TestArea`, `\\.\Galaxy!`, etc., all returned
rc=0 but matched no traffic — they were not the canonical form.
2. **`InitializeConsumer` before `RegisterConsumer`** — already
discovered earlier; bug-fix for PR A.5's `AlarmClientConsumer`.
With both in place, `GetHighPriAlarm` returned a record on every
poll for 60s straight (117/117 calls), but threw
`ArgumentOutOfRangeException: Not a valid Win32 FileTime` instead
of returning successfully — the AlarmRecord struct contains five
DateTime fields (`ar_Time`, `ar_OrigTime`, `ar_AckTime`,
`ar_RtnTime`, `ar_SubTime`) and AVEVA writes sentinel/invalid
FILETIME values for unset ones (e.g., `ar_AckTime` for an
unacknowledged alarm). The .NET interop that AVEVA ships
(`aaAlarmManagedClient.dll`) auto-converts FILETIME→DateTime and
rejects out-of-range values.
`GetStatistics` continues to report `total=0 active=0` even with
GetHighPriAlarm returning records — those two API surfaces have
genuinely different views in AVEVA's data model.
So: **alarms flow through `aaAlarmManagedClient.AlarmClient` once
the subscription expression is canonical**. The blocking issue is
extracting the payload past the .NET interop's DateTime
auto-marshaling.
## Remaining work to capture alarm payloads
Define a custom COM interop that uses `long` (FILETIME-as-int64)
instead of `DateTime` for the timestamp fields. Approach options:
1. **Patch the AVEVA-shipped `aaAlarmManagedClient.dll`** — ildasm
the assembly, replace `DateTime` with `long` on AlarmRecord's
timestamp fields, ilasm back. Brittle across AVEVA upgrades.
2. **Write our own `[ComImport]` interface** — declare
`IRawAlarmConsumer` ourselves with safe-blittable types,
discover the underlying COM IID (via reflection on
`AlarmClient`'s `[Guid]` attribute), and `(IRawAlarmConsumer)
alarmClient` cast. Cleaner; requires the IID.
3. **Use `IDispatch` late binding** — dispatch-Invoke bypasses
strong-typed marshaling. Verbose but doesn't need IIDs.
For PR A.2's worker integration, option 2 is the least
disruptive. Once the interop is custom, `AlarmClient.Subscribe` +
`GetHighPriAlarm` + `GetAlarmExtendedRec` form a viable
polling-style alarm consumer.
**REVISED 2026-05-01 — option 1 not directly applicable.**
Reflection on `aaAlarmManagedClient.AlarmClient` shows it
implements only `IDisposable` (no `[ComImport]` interface, no
class GUID). It has a single field `CwwAlarmConsumer*
m_almUnmanaged` — meaning `AlarmClient` is a **C++/CLI managed
wrapper around a native C++ class**, NOT a COM-interop class.
The DateTime conversion happens inside the AVEVA wrapper's IL,
not at a .NET-to-COM marshaling boundary. There is no separate
COM interface IID we can QI to.
Revised approach options:
A. **Switch to `wnwrapConsumer.dll`** — a separate standalone
COM library AVEVA ships at
`C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll`
exposing `WNWRAPCONSUMERLib.wwAlarmConsumerClass` with
`SetXmlAlarmQuery` / `GetXmlCurrentAlarms`. XML-string output
bypasses FILETIME marshaling entirely.
B. **Patch `aaAlarmManagedClient.dll` IL** — wrap the unsafe
`DateTime.FromFileTime` calls with a safe variant. Direct
fix but modifies a vendor binary.
C. **Reflect into `m_almUnmanaged` and call native vtable** —
get the IntPtr, walk the MSVC C++ vtable, call
`__thiscall` methods via `Marshal.GetDelegateForFunctionPointer`.
Doable but requires reverse-engineering the C++ class layout.
Option A is the best fit: real COM-based, self-contained in
our code, conventional production-grade approach (the WIN-911
consumer pattern referenced in AVEVA support forums uses it).
The polling-vs-WM_APP-callback question from earlier is now
moot: `GetStatistics`'s `positions[]/handles[]` arrays remained
empty even when alarms were demonstrably present. The active
read API for current alarms is `GetHighPriAlarm`, not
`GetStatistics`'s change array.
### 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:
- **`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.
## Option A — captured, 2026-05-01
`wnwrapConsumer.dll` (`C:\Program Files (x86)\Common Files\
ArchestrA\wnwrapConsumer.dll`) hosts the standalone COM class
`WNWRAPCONSUMERLib.wwAlarmConsumerClass`. Type library imports
cleanly via `tlbimp` (output stored under `mxaccessgw/lib/
Interop.WNWRAPCONSUMERLib.dll`). The COM class is registered in
`HKLM:\SOFTWARE\WOW6432Node\Classes\CLSID\
{7AB52E5F-36B2-4A30-AE46-952A746F667C}` with `ThreadingModel=
Apartment` — `new wwAlarmConsumerClass()` succeeds via
`CoCreateInstance`.
The probe `MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs`
(Skip-gated, archival) drove the captured run. Lifecycle:
1. `new wwAlarmConsumerClass()` — instantiated.
2. `InitializeConsumer("MxGatewayProbe.WnWrap")` -> 0.
3. `RegisterConsumer(hWnd: 0, productName, applicationName,
version)` -> 0. **Note:** wnwrap's `RegisterConsumer` is
4-arg (no `bRetainHiddenAlarms`); `aaAlarmManagedClient`'s
is 5-arg. Different surface.
4. `Subscribe(@"\\<machine>\Galaxy!DEV", priLow=1, priHigh=999,
qtSummary, sfReturnNewestFirst, asAlarmActiveNow,
asAlarmActiveNow)` -> 0. Same canonical scope that worked
for `aaAlarmManagedClient`.
5. `SetXmlAlarmQuery(...)` was called too but the round-trip
`GetXmlAlarmQuery` returned a mangled echo (NODE became
`DESKTOP-6JL3KKO\Galaxy!DEV`, PROVIDER became `Galaxy!DEV`,
ALARM_STATE shortened to `All`, DISPLAY_MODE truncated to
`Sum`). The XML-query path looks broken in this build; rely
on `Subscribe` for the filter and skip `SetXmlAlarmQuery` in
production. Confirming "Subscribe alone is sufficient" is
one follow-up probe (call `Subscribe` and read XML, no
`SetXmlAlarmQuery`) — out of scope for the breakthrough run
but easy to verify.
### Captured XML (60 polls over 30s, 500ms cadence)
`GetXmlCurrentAlarms2(maxAlmCnt: 100, out vartCurrentXmlAlarms)`
returned BSTR XML cleanly on every call — 60/60 ok, zero throws.
`GetXmlCurrentAlarms` (the v1 method) returned identical content
on the same cadence; either method is viable.
Empty state:
```xml
<?xml version="1.0"?><ALARM_RECORDS COUNT="0"></ALARM_RECORDS>
```
With alarm active (`UNACK_ALM`, value=true after the flip
script set the bool true):
```xml
<?xml version="1.0"?>
<ALARM_RECORDS COUNT="1">
<ALARM>
<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>
<DATE>2026/5/1</DATE>
<TIME>13:26:14.709</TIME>
<GMTOFFSET>240</GMTOFFSET>
<DSTADJUST>0</DSTADJUST>
<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>
<PROVIDER_NAME>Galaxy</PROVIDER_NAME>
<GROUP>TestArea</GROUP>
<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>
<TYPE>DSC</TYPE>
<VALUE>true</VALUE>
<LIMIT>true</LIMIT>
<PRIORITY>500</PRIORITY>
<STATE>UNACK_ALM</STATE>
<OPERATOR_NODE></OPERATOR_NODE>
<OPERATOR_NAME></OPERATOR_NAME>
<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>
</ALARM>
</ALARM_RECORDS>
```
After the script set the bool false (`UNACK_RTN`, value=false):
```xml
<?xml version="1.0"?>
<ALARM_RECORDS COUNT="1">
<ALARM>
<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>
<DATE>2026/5/1</DATE>
<TIME>13:26:24.710</TIME>
...
<VALUE>false</VALUE>
<STATE>UNACK_RTN</STATE>
...
</ALARM>
</ALARM_RECORDS>
```
The 10s cadence between transitions matches the System Platform
script's flip frequency exactly. **GUID is stable across the
in→out cycle** (`BCC4705…` carried through both states), so the
XML stream represents the alarm record's lifecycle, not separate
event records — this is "current alarms snapshot," not
"transition stream." For an OPC UA `AlarmConditionService`
adapter this is fine: condition-state changes per-snapshot is
the supported model.
`STATE` enum values observed: `UNACK_RTN` (the alarm has
returned to normal but is unacknowledged — i.e., visible in the
"current alarms" list because operator hasn't acked it yet) and
`UNACK_ALM` (the alarm is currently active and unacknowledged).
The other states from `eAlmState` (`ACK_RTN`, `ACK_ALM`) would
appear when an ack is performed — `wwAlarmConsumerClass.AlarmAckByGUID`
is the method to call.
### `GetStatistics` AV — unrelated quirk
Every `GetStatistics` call threw `AccessViolationException` in
the probe. Cause: the wnwrap interop signature uses `IntPtr` for
the three array out-parameters (`pChangeCode`, `pChangePos`,
`phAlarm`); passing `IntPtr.Zero` is wrong — the COM impl is
writing into the buffer pointer without null-checking. Pre-
allocate three int-arrays and pass pinned pointers (or use
`Marshal.AllocCoTaskMem`) to fix. Not required for the
production path — the XML methods give us everything we need.
### Implications for PR A.2 worker integration
Replacing `aaAlarmManagedClient.AlarmClient` with
`WNWRAPCONSUMERLib.wwAlarmConsumerClass` in the worker's
alarm-consumer surface unblocks A.2 fully. Outline:
1. **Reference path:** drop `aaAlarmManagedClient.dll` reference
from `MxGateway.Worker.csproj`; add `Interop.WNWRAPCONSUMERLib.dll`
reference from `mxaccessgw/lib/`. (Or commit the interop dll
in-tree under `lib/` and reference relatively.)
2. **`AlarmClientConsumer` → `WnWrapAlarmConsumer`:** rewrite
the consumer wrapper to:
- `new wwAlarmConsumerClass()` on the worker's STA thread.
- `InitializeConsumer(applicationName)` then
`RegisterConsumer(hWnd: 0, …)`.
- `Subscribe(@"\\<node>\Galaxy!<area>", …)` per configured
area. The `<node>` and `<area>` are configurable (default
`Environment.MachineName` + the platform's primary area).
- Poll `GetXmlCurrentAlarms2(maxAlmCnt, out xml)` on a
timer (500ms-1s cadence is comfortable). Parse XML
payload; diff against the previous snapshot (keyed by
`GUID`); emit `MX_EVENT_FAMILY_ON_ALARM_TRANSITION`
events for added/changed/removed records.
- `AlarmAckByGUID(VBGUID, comment, oprName, node, domain,
fullName)` for client-driven acknowledgements (matches
PR A.5's `AlarmAckCommand` payload).
- Lifecycle teardown: `DeregisterConsumer` +
`UninitializeConsumer` + `Marshal.FinalReleaseComObject`.
3. **Conversion layer:** map XML record fields to
`MxAlarmConditionRecord` proto:
- `GUID` → `condition_id` (canonicalize the no-dashes hex
to a UUID string).
- `STATE` enum → `inAlarm` + `acked` booleans
(`UNACK_ALM` → in_alarm=true, acked=false;
`UNACK_RTN` → in_alarm=false, acked=false;
`ACK_ALM` → in_alarm=true, acked=true;
`ACK_RTN` → in_alarm=false, acked=true).
- `DATE + TIME + GMTOFFSET + DSTADJUST` → reassemble UTC
timestamp; matches the worker's existing `Timestamp`
wire format.
- `PRIORITY` → severity (already 1-1000-ish range).
- `TAGNAME` → reference; `PROVIDER_NAME` + `GROUP` for
scope metadata.
4. **PR A.5 fix carry-over:** `InitializeConsumer` MUST be
called before `RegisterConsumer` (rediscovered with
`aaAlarmManagedClient`, also true here). The existing
`AlarmClientConsumer` skips Initialize entirely; the new
`WnWrapAlarmConsumer` includes it from day one.
5. **Test reuse:** PR A.5's snapshot/ack contract tests can
stay — they don't touch the underlying COM API. Add a new
integration test against the wnwrap surface (live-AVEVA-only,
Skip-gated like the probe).
### Settled API-ordering and surface knowledge
- `InitializeConsumer` first, then `RegisterConsumer` — both
on `aaAlarmManagedClient.AlarmClient` and
`wwAlarmConsumerClass`.
- `RegisterConsumer` arity differs:
`aaAlarmManagedClient.AlarmClient.RegisterConsumer(hWnd,
product, app, version, bRetainHiddenAlarms)` — 5 args;
`wwAlarmConsumerClass.RegisterConsumer(hWnd, product, app,
version)` — 4 args. The wnwrap class has no
`bRetainHiddenAlarms` parameter at all.
- Subscription expression format: `\\<machine>\Galaxy!<area>`
(literal `Galaxy` provider) for both libraries.
- Native ack: `AlarmAckByGUID(VBGUID guid, comment, oprName,
node, domain, fullName)` on the v2 surface; ID 5-arg
variant on the legacy `IwwAlarmConsumer` interface.
These findings retire the open follow-up probes from the
"polling-vs-pump" debate above — `wwAlarmConsumerClass` plus
poll-on-timer is the implementation.
## Live smoke-test discoveries — 2026-05-01
The Skip-gated `AlarmsLiveSmokeTests.Alarms_full_pipeline_round_trip`
ran the full
`WnWrapAlarmConsumer` + `AlarmDispatcher` + `MxAccessAlarmEventSink`
pipeline against the dev rig with the flip script running. End-to-end
verified: 6 real transitions captured on the 10s cadence, ack-by-name
returned rc=0, pipeline stayed healthy through 5 more transitions
afterwards. Three production-relevant quirks surfaced and were fixed
in the consumer:
### 1. `SetXmlAlarmQuery` is mandatory for reads despite the mangled echo
Without `SetXmlAlarmQuery`, the first `GetXmlCurrentAlarms2` call
fails with `E_FAIL` (HRESULT `0x80004005`). The discovery doc above
flagged the round-trip echo as mangled and recommended skipping the
call — that recommendation is **wrong**. The echo *is* mangled (AVEVA
parses NODE/PROVIDER/ALARM_STATE/DISPLAY_MODE incorrectly), but the
call itself is required as some kind of subscription enabler. Even
the Subscribe call setting the actual filter doesn't avoid the need
for `SetXmlAlarmQuery`.
`WnWrapAlarmConsumer.ComposeXmlAlarmQuery(subscription)` decomposes
the canonical `\\<machine>\Galaxy!<area>` form into the XML's
NODE/PROVIDER/GROUP fields. Mangled or not, the call enables reads.
### 2. Two consumers required: read-side vs. ack-side
`SetXmlAlarmQuery` enables reads but **breaks `AlarmAckByName` on
the same consumer instance**. With SetXml applied, AlarmAckByName
returns -55 even with valid name+provider+group+operator. Without
SetXml, AlarmAckByName succeeds with rc=0.
The production consumer therefore provisions **two** wnwrap COM
instances:
- Primary consumer (`client`): runs full lifecycle including
`SetXmlAlarmQuery` for `GetXmlCurrentAlarms2` polls.
- Ack-only consumer (`ackClient`): runs Initialize → Register →
Subscribe via the v1-prefixed methods, **no SetXmlAlarmQuery**.
All `AcknowledgeByName` calls dispatch through this instance.
Both consumers subscribe to the same expression. Disposal cleans up
both via a shared `ReleaseConsumerCom` helper.
### 3. `AlarmAckByName` v2 8-arg vs. v1 6-arg
`wwAlarmConsumerClass` exposes two `AlarmAckByName` overloads:
- `IwwAlarmConsumer2` v2: 8 args (`name, provider, group, comment,
oprName, node, domainName, oprFullName`).
- `IwwAlarmConsumer` v1: 6 args (no domain, no full-name).
The v2 8-arg method returns -55 on this AVEVA build regardless of
operator-identity inputs — looks like a stub. The v1 6-arg method
works. Production `WnWrapAlarmConsumer.AcknowledgeByName` calls the
6-arg overload and discards the proto's `domain` + `full_name` fields.
The proto contract keeps the 8 fields for forward compatibility if
AVEVA fixes the v2 method later.
### 4. `AlarmAckByGUID` is not implemented
The v2 `AlarmAckByGUID(VBGUID, …)` throws `NotImplementedException`
(COM `E_NOTIMPL`) on `wwAlarmConsumerClass` against this AVEVA
build. The reference→GUID lookup that we initially planned to wire
through `AlarmAckByGUID` is therefore not viable on wnwrap; all acks
must go through `AlarmAckByName`.
The proto `AcknowledgeAlarmCommand` (GUID-based) and the worker's
`MxAccessCommandExecutor.ExecuteAcknowledgeAlarm` switch arm remain
in the codebase for the forward-compat shape, but the gateway-side
`WorkerAlarmRpcDispatcher.AcknowledgeAsync` now always routes through
`AcknowledgeAlarmByName` when the public RPC supplies a recognizable
`Provider!Group.Tag` reference.
### 5. STA / threading — production fix needed
The wnwrap COM is `ThreadingModel=Apartment`. The consumer's
internal `Timer` fires on threadpool threads and would block forever
on cross-apartment marshaling unless the host STA pumps Win32
messages. The smoke test sidesteps this by setting
`pollIntervalMilliseconds=0` (Timer disabled) and driving `PollOnce`
manually from the test's STA. Production hosting will route polls
through the worker's `StaRuntime` in a follow-up — the consumer's
`PollOnce` is `public` and idempotent so the wire-up is mechanical.
### Capture summary
```
Transition: kind=Clear ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' …
Transition: kind=Raise ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' …
SnapshotActiveAlarms count=1
active: ref='Galaxy!TestArea.TestMachine_001.TestAlarm001' state=Active
AcknowledgeByName(real identity) -> rc=0
Post-ack transition: kind=Clear …
+1: kind=Raise … (10s after ack)
+2: kind=Clear … (20s)
+3: kind=Raise … (30s)
+4: kind=Clear … (40s)
```
10s cadence held throughout; full proto fields populated correctly;
ack registered server-side without errors.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -88,6 +88,11 @@ message MxCommand {
UnAdviseItemBulkCommand un_advise_item_bulk = 31; UnAdviseItemBulkCommand un_advise_item_bulk = 31;
SubscribeBulkCommand subscribe_bulk = 32; SubscribeBulkCommand subscribe_bulk = 32;
UnsubscribeBulkCommand unsubscribe_bulk = 33; UnsubscribeBulkCommand unsubscribe_bulk = 33;
SubscribeAlarmsCommand subscribe_alarms = 34;
UnsubscribeAlarmsCommand unsubscribe_alarms = 35;
AcknowledgeAlarmCommand acknowledge_alarm_command = 36;
QueryActiveAlarmsCommand query_active_alarms_command = 37;
AcknowledgeAlarmByNameCommand acknowledge_alarm_by_name_command = 38;
PingCommand ping = 100; PingCommand ping = 100;
GetSessionStateCommand get_session_state = 101; GetSessionStateCommand get_session_state = 101;
GetWorkerInfoCommand get_worker_info = 102; GetWorkerInfoCommand get_worker_info = 102;
@@ -122,6 +127,11 @@ enum MxCommandKind {
MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK = 22; MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK = 22;
MX_COMMAND_KIND_SUBSCRIBE_BULK = 23; MX_COMMAND_KIND_SUBSCRIBE_BULK = 23;
MX_COMMAND_KIND_UNSUBSCRIBE_BULK = 24; MX_COMMAND_KIND_UNSUBSCRIBE_BULK = 24;
MX_COMMAND_KIND_SUBSCRIBE_ALARMS = 25;
MX_COMMAND_KIND_UNSUBSCRIBE_ALARMS = 26;
MX_COMMAND_KIND_ACKNOWLEDGE_ALARM = 27;
MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS = 28;
MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME = 29;
MX_COMMAND_KIND_PING = 100; MX_COMMAND_KIND_PING = 100;
MX_COMMAND_KIND_GET_SESSION_STATE = 101; MX_COMMAND_KIND_GET_SESSION_STATE = 101;
MX_COMMAND_KIND_GET_WORKER_INFO = 102; MX_COMMAND_KIND_GET_WORKER_INFO = 102;
@@ -263,6 +273,63 @@ message SubscribeBulkCommand {
repeated string tag_addresses = 2; repeated string tag_addresses = 2;
} }
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
// Subscription expression follows the canonical
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
// worker spins up a wnwrapConsumer-backed subscription on its STA on
// first call; subsequent calls are an error (use UnsubscribeAlarms then
// SubscribeAlarms to reconfigure).
message SubscribeAlarmsCommand {
string subscription_expression = 1;
}
// Tear down the worker's alarm consumer. No-op if no subscription is
// currently active.
message UnsubscribeAlarmsCommand {
}
// Acknowledge a single alarm by its GUID. Operator identity fields are
// recorded atomically with the ack transition in the alarm-history log.
// The reply's hresult / native_status surfaces AVEVA's
// AlarmAckByGUID return code.
message AcknowledgeAlarmCommand {
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
string alarm_guid = 1;
string comment = 2;
string operator_user = 3;
string operator_node = 4;
string operator_domain = 5;
string operator_full_name = 6;
}
// Snapshot the currently-active alarm set. Optional filter prefix scopes
// the snapshot to alarms whose alarm_full_reference starts with the
// supplied string (matches QueryActiveAlarmsRequest.alarm_filter_prefix).
message QueryActiveAlarmsCommand {
string alarm_filter_prefix = 1;
}
// Acknowledge a single alarm by its (name, provider, group) tuple. Used
// when the public RPC's AlarmFullReference (Provider!Group.Tag) cannot
// be resolved to a GUID directly. The worker invokes
// wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm
// history path as AlarmAckByGUID.
message AcknowledgeAlarmByNameCommand {
// Tag/alarm name (e.g. "TestMachine_001.TestAlarm001"). Tag itself
// may contain dots; the gateway-side parser splits on the first dot
// after the '!' separator.
string alarm_name = 1;
// AVEVA alarm-provider name (literal "Galaxy" for ArchestrA Galaxies).
string provider_name = 2;
// Area/group name (e.g. "TestArea").
string group_name = 3;
string comment = 4;
string operator_user = 5;
string operator_node = 6;
string operator_domain = 7;
string operator_full_name = 8;
}
message UnsubscribeBulkCommand { message UnsubscribeBulkCommand {
int32 server_handle = 1; int32 server_handle = 1;
repeated int32 item_handles = 2; repeated int32 item_handles = 2;
@@ -314,6 +381,8 @@ message MxCommandReply {
BulkSubscribeReply un_advise_item_bulk = 31; BulkSubscribeReply un_advise_item_bulk = 31;
BulkSubscribeReply subscribe_bulk = 32; BulkSubscribeReply subscribe_bulk = 32;
BulkSubscribeReply unsubscribe_bulk = 33; BulkSubscribeReply unsubscribe_bulk = 33;
AcknowledgeAlarmReplyPayload acknowledge_alarm = 34;
QueryActiveAlarmsReplyPayload query_active_alarms = 35;
SessionStateReply session_state = 100; SessionStateReply session_state = 100;
WorkerInfoReply worker_info = 101; WorkerInfoReply worker_info = 101;
DrainEventsReply drain_events = 102; DrainEventsReply drain_events = 102;
@@ -379,6 +448,24 @@ message DrainEventsReply {
repeated MxEvent events = 1; repeated MxEvent events = 1;
} }
// Reply payload for AcknowledgeAlarmCommand. Surfaces AVEVA's native
// AlarmAckByGUID return code; 0 means success. The MxCommandReply's
// hresult field carries the same value and is preferred for protocol
// consumers — this payload exists so the gateway-side
// WorkerAlarmRpcDispatcher can echo native_status into
// AcknowledgeAlarmReply.hresult without unpacking the outer envelope.
message AcknowledgeAlarmReplyPayload {
int32 native_status = 1;
}
// Reply payload for QueryActiveAlarmsCommand. The worker walks
// IMxAccessAlarmConsumer.SnapshotActiveAlarms and packs each record as
// an ActiveAlarmSnapshot proto for the gateway-side ConditionRefresh
// stream.
message QueryActiveAlarmsReplyPayload {
repeated ActiveAlarmSnapshot snapshots = 1;
}
message MxEvent { message MxEvent {
MxEventFamily family = 1; MxEventFamily family = 1;
string session_id = 2; string session_id = 2;
@@ -0,0 +1,48 @@
namespace MxGateway.Server.Configuration;
/// <summary>
/// Per-gateway alarm-subsystem configuration. Drives the auto-subscribe
/// hook in <see cref="Sessions.SessionManager"/>: when
/// <see cref="Enabled"/> is true and a session reaches Ready, the
/// manager issues a <c>SubscribeAlarmsCommand</c> to the worker with
/// the configured <see cref="SubscriptionExpression"/>.
/// </summary>
/// <remarks>
/// Defaults preserve current behaviour (alarms disabled). Operators
/// opt in by setting <c>MxGateway:Alarms:Enabled = true</c> and
/// supplying a canonical
/// <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> subscription
/// expression. The literal "Galaxy" provider is correct regardless of
/// the configured Galaxy database name (the wnwrap consumer doesn't
/// accept the database name as the provider).
/// </remarks>
public sealed class AlarmsOptions
{
/// <summary>Gate the auto-subscribe hook on session open. Default false.</summary>
public bool Enabled { get; init; }
/// <summary>
/// AVEVA alarm-subscription expression. When empty and
/// <see cref="Enabled"/> is true, the gateway falls back to
/// <c>\\$(MachineName)\Galaxy!$(DefaultArea)</c> if
/// <see cref="DefaultArea"/> is set; otherwise the session open
/// fails with a configuration diagnostic.
/// </summary>
public string SubscriptionExpression { get; init; } = string.Empty;
/// <summary>
/// Optional area name used to compose a default subscription when
/// <see cref="SubscriptionExpression"/> is empty. Combined with
/// <c>Environment.MachineName</c> as
/// <c>\\&lt;MachineName&gt;\Galaxy!&lt;DefaultArea&gt;</c>.
/// </summary>
public string DefaultArea { get; init; } = string.Empty;
/// <summary>
/// If true, an auto-subscribe failure faults the session. If false
/// (default), the failure is logged and the session remains Ready —
/// alarm-side commands return "not subscribed" but data subscriptions
/// work normally.
/// </summary>
public bool RequireSubscribeOnOpen { get; init; }
}
@@ -35,4 +35,11 @@ public sealed class GatewayOptions
/// Gets protocol configuration options. /// Gets protocol configuration options.
/// </summary> /// </summary>
public ProtocolOptions Protocol { get; init; } = new(); public ProtocolOptions Protocol { get; init; } = new();
/// <summary>
/// Gets alarm-subsystem configuration options. Drives the gateway's
/// auto-subscribe-on-session-open hook; default values preserve legacy
/// behaviour (alarms disabled).
/// </summary>
public AlarmsOptions Alarms { get; init; } = new();
} }
@@ -87,6 +87,8 @@ public sealed class SessionManager : ISessionManager
session.MarkReady(); session.MarkReady();
_metrics.SessionOpened(); _metrics.SessionOpened();
await TryAutoSubscribeAlarmsAsync(session, cancellationToken).ConfigureAwait(false);
return session; return session;
} }
catch (Exception exception) catch (Exception exception)
@@ -396,4 +398,101 @@ public sealed class SessionManager : ISessionManager
return Convert.ToBase64String(bytes); return Convert.ToBase64String(bytes);
} }
/// <summary>
/// If <c>Alarms.Enabled</c> is configured, issue a
/// <c>SubscribeAlarmsCommand</c> on the freshly-Ready session so the
/// worker's wnwrap consumer starts polling. Failure handling is
/// governed by <c>Alarms.RequireSubscribeOnOpen</c>:
/// <list type="bullet">
/// <item><description><c>true</c> — propagate the failure to fault the session.</description></item>
/// <item><description><c>false</c> (default) — log a warning and let the session continue serving data subscriptions.</description></item>
/// </list>
/// </summary>
private async Task TryAutoSubscribeAlarmsAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
AlarmsOptions alarms = _options.Alarms;
if (!alarms.Enabled) return;
string subscription = ResolveAlarmSubscription(alarms);
if (string.IsNullOrWhiteSpace(subscription))
{
const string diagnostic =
"Alarms.Enabled is true but no SubscriptionExpression / DefaultArea is configured.";
if (alarms.RequireSubscribeOnOpen)
{
throw new SessionManagerException(
SessionManagerErrorCode.OpenFailed, diagnostic);
}
_logger.LogWarning(
"Auto-subscribe skipped for session {SessionId}: {Diagnostic}",
session.SessionId, diagnostic);
return;
}
WorkerCommand command = new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = subscription,
},
},
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
};
try
{
WorkerCommandReply reply = await session.InvokeAsync(command, cancellationToken)
.ConfigureAwait(false);
ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code;
if (code != ProtocolStatusCode.Ok)
{
string diagnostic = reply.Reply?.DiagnosticMessage
?? reply.Reply?.ProtocolStatus?.Message
?? "Worker rejected SubscribeAlarms.";
if (alarms.RequireSubscribeOnOpen)
{
throw new SessionManagerException(
SessionManagerErrorCode.OpenFailed,
$"Auto-subscribe failed for session {session.SessionId}: {diagnostic}");
}
_logger.LogWarning(
"Auto-subscribe failed for session {SessionId} (status {StatusCode}): {Diagnostic}",
session.SessionId, code, diagnostic);
return;
}
_logger.LogInformation(
"Alarm auto-subscribe succeeded for session {SessionId} on {Subscription}.",
session.SessionId, subscription);
}
catch (SessionManagerException)
{
throw;
}
catch (Exception ex) when (!alarms.RequireSubscribeOnOpen)
{
_logger.LogWarning(
ex,
"Auto-subscribe threw for session {SessionId} on {Subscription}; alarm path remains inactive.",
session.SessionId, subscription);
}
}
private static string ResolveAlarmSubscription(AlarmsOptions alarms)
{
if (!string.IsNullOrWhiteSpace(alarms.SubscriptionExpression))
{
return alarms.SubscriptionExpression;
}
if (!string.IsNullOrWhiteSpace(alarms.DefaultArea))
{
return $@"\\{Environment.MachineName}\Galaxy!{alarms.DefaultArea}";
}
return string.Empty;
}
} }
@@ -11,6 +11,7 @@ public static class SessionServiceCollectionExtensions
services.AddSingleton<ISessionRegistry, SessionRegistry>(); services.AddSingleton<ISessionRegistry, SessionRegistry>();
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>(); services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
services.AddSingleton<ISessionManager, SessionManager>(); services.AddSingleton<ISessionManager, SessionManager>();
services.AddSingleton<IAlarmRpcDispatcher, WorkerAlarmRpcDispatcher>();
services.AddHostedService<SessionLeaseMonitorHostedService>(); services.AddHostedService<SessionLeaseMonitorHostedService>();
services.AddHostedService<SessionShutdownHostedService>(); services.AddHostedService<SessionShutdownHostedService>();
@@ -0,0 +1,231 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Grpc;
namespace MxGateway.Server.Sessions;
/// <summary>
/// Production <see cref="IAlarmRpcDispatcher"/> that routes the public
/// <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c> RPCs through the
/// worker pipe IPC. Replaces <see cref="NotWiredAlarmRpcDispatcher"/>
/// once the worker AlarmCommandHandler is wired in.
/// </summary>
/// <remarks>
/// <para>
/// <c>QueryActiveAlarms</c> is fully wired: issues a
/// <see cref="QueryActiveAlarmsCommand"/> over the pipe and yields
/// each <see cref="ActiveAlarmSnapshot"/> from the
/// <see cref="QueryActiveAlarmsReplyPayload"/>.
/// </para>
/// <para>
/// <c>AcknowledgeAlarm</c> is partially wired: the public RPC's
/// <see cref="AcknowledgeAlarmRequest.AlarmFullReference"/> is a
/// <c>Provider!Group.Tag</c> string, but the worker's wnwrap consumer
/// acks by GUID. When the supplied reference parses as a GUID
/// directly, the dispatcher forwards it as-is. Otherwise it
/// returns an <c>Unimplemented</c> diagnostic. Resolving
/// reference→GUID requires an additional worker IPC command
/// (e.g. <c>AlarmAckByName</c> wrapping
/// <c>wwAlarmConsumerClass.AlarmAckByName</c>) and is tracked as
/// a follow-up.
/// </para>
/// </remarks>
public sealed class WorkerAlarmRpcDispatcher : IAlarmRpcDispatcher
{
private readonly ISessionRegistry sessionRegistry;
private readonly TimeProvider timeProvider;
public WorkerAlarmRpcDispatcher(ISessionRegistry sessionRegistry, TimeProvider? timeProvider = null)
{
this.sessionRegistry = sessionRegistry ?? throw new System.ArgumentNullException(nameof(sessionRegistry));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Parse a full alarm reference of the form <c>Provider!Group.Tag</c>
/// into its components. Convention: the first <c>!</c> separates
/// provider from <c>Group.Tag</c>; the first <c>.</c> after the
/// <c>!</c> separates group from tag (the tag itself may contain
/// more dots — e.g. <c>TestMachine_001.TestAlarm001</c>).
/// </summary>
/// <returns>true on a well-formed reference; false otherwise.</returns>
public static bool TryParseAlarmReference(
string? reference,
out string providerName,
out string groupName,
out string alarmName)
{
providerName = string.Empty;
groupName = string.Empty;
alarmName = string.Empty;
if (string.IsNullOrWhiteSpace(reference)) return false;
int bang = reference!.IndexOf('!');
if (bang <= 0 || bang == reference.Length - 1) return false;
string left = reference[..bang];
string right = reference[(bang + 1)..];
int dot = right.IndexOf('.');
if (dot <= 0 || dot == right.Length - 1) return false;
providerName = left;
groupName = right[..dot];
alarmName = right[(dot + 1)..];
return true;
}
/// <inheritdoc />
public async Task<AcknowledgeAlarmReply> AcknowledgeAsync(
AcknowledgeAlarmRequest request,
CancellationToken cancellationToken)
{
if (request is null) throw new System.ArgumentNullException(nameof(request));
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session))
{
return new AcknowledgeAlarmReply
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = MxAccessGrpcMapper.SessionNotFound(
$"Session '{request.SessionId}' not found."),
DiagnosticMessage = "AcknowledgeAlarm: session not found.",
};
}
WorkerCommand workerCommand;
if (System.Guid.TryParse(request.AlarmFullReference, out System.Guid guid))
{
workerCommand = new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = guid.ToString(),
Comment = request.Comment ?? string.Empty,
OperatorUser = request.OperatorUser ?? string.Empty,
// Operator node/domain/full-name are not on the public
// RPC surface today; pass empty strings so the worker
// honours the existing AcknowledgeAlarmCommand schema.
OperatorNode = string.Empty,
OperatorDomain = string.Empty,
OperatorFullName = string.Empty,
},
},
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
};
}
else if (TryParseAlarmReference(
request.AlarmFullReference,
out string providerName,
out string groupName,
out string alarmName))
{
workerCommand = new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = alarmName,
ProviderName = providerName,
GroupName = groupName,
Comment = request.Comment ?? string.Empty,
OperatorUser = request.OperatorUser ?? string.Empty,
OperatorNode = string.Empty,
OperatorDomain = string.Empty,
OperatorFullName = string.Empty,
},
},
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
};
}
else
{
return new AcknowledgeAlarmReply
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
Message = "AlarmFullReference must be a canonical GUID or 'Provider!Group.Tag' format.",
},
DiagnosticMessage = $"AcknowledgeAlarm received unrecognized reference '{request.AlarmFullReference}'.",
};
}
WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken)
.ConfigureAwait(false);
MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply
{
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.ProtocolViolation,
Message = "Worker reply did not include an MxCommandReply.",
},
};
AcknowledgeAlarmReply reply = new AcknowledgeAlarmReply
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = mxReply.ProtocolStatus ?? MxAccessGrpcMapper.Ok(),
DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty,
};
if (mxReply.HasHresult)
{
reply.Hresult = mxReply.Hresult;
}
return reply;
}
/// <inheritdoc />
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (request is null) throw new System.ArgumentNullException(nameof(request));
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session))
{
yield break;
}
WorkerCommand workerCommand = new WorkerCommand
{
Command = new MxCommand
{
Kind = MxCommandKind.QueryActiveAlarms,
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand
{
AlarmFilterPrefix = request.AlarmFilterPrefix ?? string.Empty,
},
},
EnqueueTimestamp = Timestamp.FromDateTimeOffset(timeProvider.GetUtcNow()),
};
WorkerCommandReply workerReply = await session.InvokeAsync(workerCommand, cancellationToken)
.ConfigureAwait(false);
MxCommandReply? mxReply = workerReply.Reply;
if (mxReply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok) yield break;
QueryActiveAlarmsReplyPayload? payload = mxReply.QueryActiveAlarms;
if (payload is null) yield break;
foreach (ActiveAlarmSnapshot snapshot in payload.Snapshots)
{
cancellationToken.ThrowIfCancellationRequested();
yield return snapshot;
}
}
}
@@ -0,0 +1,266 @@
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Pins the alarm auto-subscribe hook on session open. Runs in
/// its own file because the cases are orthogonal to
/// <see cref="SessionManagerTests"/> (alarms-disabled vs.
/// alarms-enabled lanes), and the fake worker client below verifies
/// the issued <c>SubscribeAlarms</c> command shape directly.
/// </summary>
public sealed class SessionManagerAlarmAutoSubscribeTests
{
[Fact]
public async Task OpenSessionAsync_DoesNotAutoSubscribe_WhenAlarmsDisabled()
{
AlarmAutoSubscribeWorkerClient worker = new();
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions { Enabled = false });
await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
}
[Fact]
public async Task OpenSessionAsync_AutoSubscribes_WhenEnabledWithExpression()
{
AlarmAutoSubscribeWorkerClient worker = new();
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
});
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
Assert.Equal(@"\\HOST\Galaxy!Area1",
worker.LastSubscribeAlarmsCommand!.SubscriptionExpression);
}
[Fact]
public async Task OpenSessionAsync_FallsBackToDefaultArea_WhenExpressionEmpty()
{
AlarmAutoSubscribeWorkerClient worker = new();
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
DefaultArea = "DEV",
});
await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
Assert.Contains(@"\Galaxy!DEV",
worker.LastSubscribeAlarmsCommand!.SubscriptionExpression);
}
[Fact]
public async Task OpenSessionAsync_Succeeds_WhenAutoSubscribeFailsWithRequireOff()
{
// Worker rejects the SubscribeAlarms command. With RequireSubscribeOnOpen=false
// (the default), the session still opens — alarm-side commands later return
// "not subscribed", but data subscriptions work.
AlarmAutoSubscribeWorkerClient worker = new()
{
SubscribeAlarmsReplyFactory = _ => new MxCommandReply
{
Kind = MxCommandKind.SubscribeAlarms,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = "wnwrap subscribe failed",
},
DiagnosticMessage = "alarm provider unavailable",
},
};
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
RequireSubscribeOnOpen = false,
});
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(1, worker.SubscribeAlarmsInvokeCount);
}
[Fact]
public async Task OpenSessionAsync_Throws_WhenAutoSubscribeFailsWithRequireOn()
{
AlarmAutoSubscribeWorkerClient worker = new()
{
SubscribeAlarmsReplyFactory = _ => new MxCommandReply
{
Kind = MxCommandKind.SubscribeAlarms,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = "wnwrap subscribe failed",
},
},
};
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
SubscriptionExpression = @"\\HOST\Galaxy!Area1",
RequireSubscribeOnOpen = true,
});
await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(
CreateOpenRequest(), "client-1", CancellationToken.None));
}
[Fact]
public async Task OpenSessionAsync_Throws_WhenEnabledButNoExpressionAndRequireOn()
{
AlarmAutoSubscribeWorkerClient worker = new();
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
// No SubscriptionExpression and no DefaultArea.
RequireSubscribeOnOpen = true,
});
await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(
CreateOpenRequest(), "client-1", CancellationToken.None));
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
}
[Fact]
public async Task OpenSessionAsync_Succeeds_WhenEnabledButNoExpressionAndRequireOff()
{
AlarmAutoSubscribeWorkerClient worker = new();
SessionManager manager = NewManager(worker, alarms: new AlarmsOptions
{
Enabled = true,
// No SubscriptionExpression and no DefaultArea — default require=false.
});
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, worker.SubscribeAlarmsInvokeCount);
}
private static SessionManager NewManager(
AlarmAutoSubscribeWorkerClient worker,
AlarmsOptions alarms)
{
FakeSessionWorkerClientFactory factory = new(worker);
GatewayOptions options = new GatewayOptions
{
Sessions = new SessionOptions
{
DefaultCommandTimeoutSeconds = 30,
MaxSessions = 64,
DefaultLeaseSeconds = 1800,
},
Worker = new WorkerOptions
{
StartupTimeoutSeconds = 30,
ShutdownTimeoutSeconds = 10,
},
Alarms = alarms,
};
return new SessionManager(
new SessionRegistry(),
factory,
Options.Create(options),
new GatewayMetrics());
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(
RequestedBackend: null,
ClientSessionName: "test-session",
ClientCorrelationId: "client-correlation-1",
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
}
private sealed class FakeSessionWorkerClientFactory(IWorkerClient client) : ISessionWorkerClientFactory
{
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
return Task.FromResult(client);
}
}
private sealed class AlarmAutoSubscribeWorkerClient : IWorkerClient
{
public string SessionId { get; } = "session-1";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int SubscribeAlarmsInvokeCount { get; private set; }
public SubscribeAlarmsCommand? LastSubscribeAlarmsCommand { get; private set; }
public Func<WorkerCommand, MxCommandReply>? SubscribeAlarmsReplyFactory { get; init; }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken)
{
if (command.Command?.Kind == MxCommandKind.SubscribeAlarms)
{
SubscribeAlarmsInvokeCount++;
LastSubscribeAlarmsCommand = command.Command.SubscribeAlarms;
MxCommandReply reply = SubscribeAlarmsReplyFactory?.Invoke(command)
?? new MxCommandReply
{
Kind = MxCommandKind.SubscribeAlarms,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = "OK",
},
};
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
return Task.FromResult(new WorkerCommandReply
{
Reply = new MxCommandReply
{
Kind = command.Command?.Kind ?? MxCommandKind.Unspecified,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = "OK",
},
},
});
}
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
=> Task.CompletedTask;
public void Kill(string reason) { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Pins the production <see cref="WorkerAlarmRpcDispatcher"/>'s behaviour:
/// resolves the session by id, issues the matching MxCommand over the
/// worker pipe, and unwraps the reply into AcknowledgeAlarmReply or the
/// ActiveAlarmSnapshot stream.
/// </summary>
public sealed class WorkerAlarmRpcDispatcherTests
{
[Fact]
public async Task AcknowledgeAsync_returns_session_not_found_when_session_missing()
{
SessionRegistry registry = new SessionRegistry();
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "missing",
ClientCorrelationId = "c1",
AlarmFullReference = Guid.NewGuid().ToString(),
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.SessionNotFound, reply.ProtocolStatus.Code);
}
[Fact]
public async Task AcknowledgeAsync_forwards_guid_and_returns_native_status()
{
SessionRegistry registry = new SessionRegistry();
Guid alarmGuid = Guid.NewGuid();
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
{
ReplyFactory = command =>
{
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command.Command.Kind);
Assert.Equal(alarmGuid.ToString(), command.Command.AcknowledgeAlarmCommand.AlarmGuid);
Assert.Equal("ack", command.Command.AcknowledgeAlarmCommand.Comment);
Assert.Equal("alice", command.Command.AcknowledgeAlarmCommand.OperatorUser);
return new MxCommandReply
{
Kind = MxCommandKind.AcknowledgeAlarm,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
Hresult = 0,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 },
};
},
};
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "s1",
ClientCorrelationId = "c1",
AlarmFullReference = alarmGuid.ToString(),
Comment = "ack",
OperatorUser = "alice",
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(0, reply.Hresult);
Assert.Equal("s1", reply.SessionId);
Assert.Equal("c1", reply.CorrelationId);
Assert.Equal(1, worker.InvokeCount);
}
[Fact]
public async Task AcknowledgeAsync_propagates_worker_diagnostic_on_failure()
{
SessionRegistry registry = new SessionRegistry();
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
{
ReplyFactory = _ => new MxCommandReply
{
Kind = MxCommandKind.AcknowledgeAlarm,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = "AVEVA Acknowledge failed.",
},
Hresult = -123,
DiagnosticMessage = "AVEVA AlarmAckByGUID returned non-zero status -123.",
},
};
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "s1",
AlarmFullReference = Guid.NewGuid().ToString(),
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Equal(-123, reply.Hresult);
Assert.Contains("-123", reply.DiagnosticMessage);
}
[Theory]
[InlineData("Galaxy!TestArea.TestMachine_001.TestAlarm001", "Galaxy", "TestArea", "TestMachine_001.TestAlarm001")]
[InlineData("Galaxy!Area.Tag", "Galaxy", "Area", "Tag")]
[InlineData("Provider!Group.Tag.With.Dots", "Provider", "Group", "Tag.With.Dots")]
public void TryParseAlarmReference_decomposes_provider_group_tag(
string reference, string expectedProvider, string expectedGroup, string expectedName)
{
Assert.True(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
reference, out string provider, out string group, out string name));
Assert.Equal(expectedProvider, provider);
Assert.Equal(expectedGroup, group);
Assert.Equal(expectedName, name);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
[InlineData("no-bang-here")]
[InlineData("!Group.Tag")] // empty provider
[InlineData("Galaxy!")] // bang at end
[InlineData("Galaxy!Group")] // missing dot
[InlineData("Galaxy!.Tag")] // empty group
[InlineData("Galaxy!Group.")] // empty tag
public void TryParseAlarmReference_rejects_malformed_references(string? reference)
{
Assert.False(WorkerAlarmRpcDispatcher.TryParseAlarmReference(
reference, out _, out _, out _));
}
[Fact]
public async Task AcknowledgeAsync_routes_provider_group_tag_via_AckByName()
{
SessionRegistry registry = new SessionRegistry();
AcknowledgeAlarmByNameCommand? observed = null;
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
{
ReplyFactory = command =>
{
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command.Command.Kind);
observed = command.Command.AcknowledgeAlarmByNameCommand;
return new MxCommandReply
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
Hresult = 0,
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = 0 },
};
},
};
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "s1",
ClientCorrelationId = "c1",
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
Comment = "ack-by-name",
OperatorUser = "bob",
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.NotNull(observed);
Assert.Equal("TestMachine_001.TestAlarm001", observed!.AlarmName);
Assert.Equal("Galaxy", observed.ProviderName);
Assert.Equal("TestArea", observed.GroupName);
Assert.Equal("bob", observed.OperatorUser);
Assert.Equal("ack-by-name", observed.Comment);
}
[Fact]
public async Task AcknowledgeAsync_returns_invalid_request_for_unparseable_reference()
{
SessionRegistry registry = new SessionRegistry();
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient();
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
AcknowledgeAlarmReply reply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "s1",
AlarmFullReference = "no-bang-no-dot",
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
Assert.Equal(0, worker.InvokeCount);
}
[Fact]
public async Task QueryActiveAlarmsAsync_yields_each_snapshot_from_payload()
{
SessionRegistry registry = new SessionRegistry();
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
{
ReplyFactory = command =>
{
Assert.Equal(MxCommandKind.QueryActiveAlarms, command.Command.Kind);
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
payload.Snapshots.Add(new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!A.T1",
CurrentState = AlarmConditionState.Active,
Severity = 500,
});
payload.Snapshots.Add(new ActiveAlarmSnapshot
{
AlarmFullReference = "Galaxy!A.T2",
CurrentState = AlarmConditionState.ActiveAcked,
Severity = 100,
});
return new MxCommandReply
{
Kind = MxCommandKind.QueryActiveAlarms,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok, Message = "OK" },
QueryActiveAlarms = payload,
};
},
};
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "s1" },
CancellationToken.None))
{
collected.Add(snap);
}
Assert.Equal(2, collected.Count);
Assert.Equal("Galaxy!A.T1", collected[0].AlarmFullReference);
Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference);
}
[Fact]
public async Task QueryActiveAlarmsAsync_yields_empty_when_session_missing()
{
SessionRegistry registry = new SessionRegistry();
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "missing" },
CancellationToken.None))
{
collected.Add(snap);
}
Assert.Empty(collected);
}
[Fact]
public async Task QueryActiveAlarmsAsync_yields_empty_on_worker_failure()
{
SessionRegistry registry = new SessionRegistry();
FakeAlarmWorkerClient worker = new FakeAlarmWorkerClient
{
ReplyFactory = _ => new MxCommandReply
{
Kind = MxCommandKind.QueryActiveAlarms,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = "alarm consumer not subscribed",
},
},
};
GatewaySession session = NewSession("s1");
session.AttachWorkerClient(worker);
session.MarkReady();
registry.TryAdd(session);
WorkerAlarmRpcDispatcher dispatcher = new WorkerAlarmRpcDispatcher(registry);
List<ActiveAlarmSnapshot> collected = new List<ActiveAlarmSnapshot>();
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "s1" },
CancellationToken.None))
{
collected.Add(snap);
}
Assert.Empty(collected);
}
private static GatewaySession NewSession(string sessionId)
{
return new GatewaySession(
sessionId,
"mxaccess",
$"mxaccess-gateway-1-{sessionId}",
"nonce",
"client-1",
"test-session",
"client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(30),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow);
}
private sealed class FakeAlarmWorkerClient : IWorkerClient
{
public string SessionId { get; } = "session-1";
public int? ProcessId { get; } = 1;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public Func<WorkerCommand, MxCommandReply>? ReplyFactory { get; set; }
public int InvokeCount { get; private set; }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken)
{
InvokeCount++;
MxCommandReply reply = ReplyFactory?.Invoke(command) ?? new MxCommandReply();
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason) { }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,779 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Linq;
using System.Reflection;
using AlarmMgrDataProviderCOM;
using aaAlarmManagedClient;
using ArchestrA.MxAccess;
using Xunit.Abstractions;
namespace MxGateway.Worker.Tests;
/// <summary>
/// Runtime probe — registers as an AlarmClient consumer with a real
/// hidden message-only window, subscribes to a Galaxy alarm provider,
/// and logs every Win32 message that arrives during a fixed pump
/// window. The intent is to identify the WM_APP / RegisterWindowMessage
/// ID that AVEVA's alarm provider posts when alarms change, plus the
/// <c>wParam</c>/<c>lParam</c> semantics on each.
///
/// Skip-gated by default; flip Skip=null and run against the live dev
/// rig to capture output. Requires:
/// <list type="bullet">
/// <item><description>A reachable Galaxy with at least one alarmable object.</description></item>
/// <item><description>The configured Galaxy expression below to match a real provider (default <c>"\\Galaxy"</c> — adjust if needed).</description></item>
/// <item><description>An alarm trigger during the pump window (raise / ack / clear something in the Galaxy via System Platform IDE) — without one, only ambient activity is captured.</description></item>
/// </list>
/// </summary>
public sealed class AlarmClientWmProbeTests : IDisposable
{
// Probe configuration. Override in the constructor below if needed.
// Try multiple subscription expressions sequentially (each Subscribe call
// adds to the consumer's scope). The "everything" form varies by AVEVA
// version — we shotgun common forms.
// Canonical AlarmClient subscription format (per ArchestrA docs):
// \\Node\Provider!Area!Filter
// - Node: machine name (NOT galaxy name; "Galaxy" is the literal provider)
// - Provider: literal "Galaxy"
// - Area: area object the engine hosts the alarm under
// Note: each Subscribe call REPLACES the prior subscription on the
// consumer, so we test exactly one expression per probe run.
private static readonly string MachineName = Environment.MachineName;
private static readonly string[] SubscriptionExpressions =
{
// DEV is the top-level area on the Platform (TestArea is contained
// within DEV). Alarms typically publish at the platform's primary
// area. If TestArea-only doesn't catch them, DEV should.
$@"\\{MachineName}\Galaxy!DEV",
};
private const string SubscriptionExpression = @"\Galaxy!";
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
private static readonly TimeSpan FireMarkerAt = TimeSpan.FromSeconds(10);
private static readonly TimeSpan ClearMarkerAt = TimeSpan.FromSeconds(35);
// Tag the operator should flip while the probe is pumping. Default
// matches the dev rig's known alarmable boolean.
private const string TriggerTagReference = "TestMachine_001.TestAlarm001";
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
private static extern IntPtr CreateWindowEx(
int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int X, int Y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
private static extern ushort RegisterClassW(ref WNDCLASSW lpWndClass);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterClassW(string lpClassName, IntPtr hInstance);
[DllImport("user32.dll")]
private static extern IntPtr DefWindowProcW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("kernel32.dll")]
private static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern uint RegisterWindowMessage(string lpString);
private const int HWND_MESSAGE = -3;
private const uint PM_REMOVE = 0x0001;
private delegate IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WNDCLASSW
{
public uint style;
public IntPtr lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
[MarshalAs(UnmanagedType.LPWStr)] public string? lpszMenuName;
[MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName;
}
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int x;
public int y;
}
private readonly ITestOutputHelper output;
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private readonly Stopwatch elapsed = Stopwatch.StartNew();
private GCHandle wndProcHandle;
private IntPtr probeWindow = IntPtr.Zero;
private string? registeredClass;
public AlarmClientWmProbeTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
public void ProbeAlarmClientWmMessages()
{
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
// matches in the captured log can be labeled. None of these is
// confirmed; we record what each resolves to so the actual AVEVA
// message ID (whatever it turns out to be) can be cross-referenced.
string[] candidateNames =
{
"WW_AlarmConsumer", "WW_AlarmManager", "WW_Alarm",
"WNAL_AlarmChange", "WNAL_AlarmChanges", "WNAL_AlarmNotify",
"WNAL_Notify", "WNAL_ChangeNotification",
"AlarmManager.Notify", "AlarmManagerNotify",
"ArchestrA.AlarmChange", "AVEVA.AlarmNotify",
"aaAlarmManagedClient.Notify",
"GotAlarmChanges", "OnAlarmChanges",
};
foreach (string name in candidateNames)
{
uint id = RegisterWindowMessage(name);
output.WriteLine($"RegisterWindowMessage(\"{name}\") -> 0x{id:X4} ({id})");
}
output.WriteLine("");
// 2. Spin up a single STA-affinitized thread, create a hidden message-
// only window owned by it, run RegisterConsumer + Subscribe against
// that window's hWnd, then pump messages on that thread for the
// configured duration. Threading discipline matches the worker's
// StaRuntime model.
Exception? threadException = null;
var pumpDone = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try
{
RunProbe();
}
catch (Exception ex)
{
threadException = ex;
}
finally
{
pumpDone.Set();
}
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
pumpDone.Wait();
thread.Join();
// 3. Drain the log to xunit output regardless of outcome — partial
// captures are still informative.
output.WriteLine("");
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line))
{
output.WriteLine(line);
}
if (threadException != null)
{
throw threadException;
}
}
private void RunProbe()
{
// 3a. Register a window class and create a message-only window.
WndProc wndProc = ProbeWndProc;
wndProcHandle = GCHandle.Alloc(wndProc); // keep delegate alive
registeredClass = "MxGatewayAlarmProbe_" + Guid.NewGuid().ToString("N");
var cls = new WNDCLASSW
{
style = 0,
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProc),
hInstance = GetModuleHandle(null!),
lpszClassName = registeredClass,
};
ushort atom = RegisterClassW(ref cls);
if (atom == 0)
{
int err = Marshal.GetLastWin32Error();
Log($"RegisterClass failed err=0x{err:X8}");
return;
}
Log($"RegisterClass ok atom=0x{atom:X4} class={registeredClass}");
probeWindow = CreateWindowEx(
dwExStyle: 0, lpClassName: registeredClass, lpWindowName: "AlarmProbe",
dwStyle: 0, X: 0, Y: 0, nWidth: 0, nHeight: 0,
hWndParent: (IntPtr)HWND_MESSAGE, hMenu: IntPtr.Zero,
hInstance: cls.hInstance, lpParam: IntPtr.Zero);
if (probeWindow == IntPtr.Zero)
{
int err = Marshal.GetLastWin32Error();
Log($"CreateWindowEx(HWND_MESSAGE) failed err=0x{err:X8}");
return;
}
Log($"Created message-only window hWnd=0x{probeWindow.ToInt64():X}");
// 3b. Create the AlarmClient and try the lifecycle. RegisterConsumer
// accepts an int hWnd — narrow the IntPtr (sufficient on x86).
AlarmClient? client = null;
try
{
client = new AlarmClient();
// One-time interop introspection: dump AlarmClient's class GUID
// (CoClass IID) and every interface it implements with their
// GUID + InterfaceType. The IID we need to redeclare with safe
// blittable types is the one whose vtable carries
// GetHighPriAlarm.
try
{
Type ct = client.GetType();
Log($"=== AlarmClient interop introspection ===");
Log($"Class FullName: {ct.FullName}");
var classGuid = ct.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
Log($"Class GUID: {classGuid?.Value ?? "(none)"}");
foreach (var iface in ct.GetInterfaces())
{
var ig = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true)
.Cast<System.Runtime.InteropServices.GuidAttribute>().FirstOrDefault();
var ity = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.InterfaceTypeAttribute), true)
.Cast<System.Runtime.InteropServices.InterfaceTypeAttribute>().FirstOrDefault();
int methodCount = iface.GetMethods().Length;
Log($" iface {iface.FullName} | GUID={ig?.Value ?? "(none)"} | type={ity?.Value.ToString() ?? "(none)"} | methods={methodCount}");
}
// Dump fields (private/internal) — the COM object reference
// is likely on a private field.
Log($"--- AlarmClient instance fields ---");
foreach (var f in ct.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
Log($" field {f.FieldType.FullName} {f.Name} (public={f.IsPublic})");
}
// Dump base class chain.
Log($"--- base class chain ---");
Type? baseT = ct.BaseType;
int depth = 0;
while (baseT != null && depth < 5)
{
Log($" base[{depth}]: {baseT.FullName}");
baseT = baseT.BaseType;
depth++;
}
Log($"=== end introspection ===");
}
catch (Exception ex)
{
Log($"Interop introspection threw: {ex.GetType().Name}: {ex.Message}");
}
// Try InitializeConsumer first — separate from RegisterConsumer
// per the discovered API surface; previous probe runs skipped
// it. Some AVEVA managed-client patterns require Initialize
// before Register; others reverse the order. Try Initialize
// first; on failure proceed to Register.
try
{
int init = client.InitializeConsumer("AlarmProbe.Tests");
Log($"InitializeConsumer -> {init}");
}
catch (Exception ex)
{
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
int register = client.RegisterConsumer(
hWnd: probeWindow.ToInt32(),
szProductName: "AlarmProbe",
szApplicationName: "AlarmProbe.Tests",
szVersion: "1.0",
bRetainHiddenAlarms: false);
Log($"RegisterConsumer -> {register}");
LogProviders(client, "after Register");
// Dump the eQueryType enum so we can see what alternatives exist
// beyond qtSummary, in case Summary aggregates and we need a
// List/Snapshot mode instead.
try
{
Type qt = typeof(eQueryType);
Log($"eQueryType enum values: " +
string.Join(", ", Enum.GetNames(qt).Select(n =>
$"{n}=0x{Convert.ToInt32(Enum.Parse(qt, n)):X}")));
Type af = typeof(eAlarmFilterState);
Log($"eAlarmFilterState enum values: " +
string.Join(", ", Enum.GetNames(af).Select(n =>
$"{n}=0x{Convert.ToInt32(Enum.Parse(af, n)):X}")));
}
catch (Exception ex)
{
Log($"Enum dump threw: {ex.Message}");
}
// qtHistory + state=ActiveNow: stream historical alarm transitions
// including active alarms. asNone for FilterMask/Spec might
// literally mean "match alarms in state 'none'" (i.e., nothing),
// since the eAlarmFilterState enum is 0/1/2/3 single-states not
// flag bits. Try ActiveNow explicitly.
// Subscribe to every candidate expression — AVEVA accepts multiple
// overlapping subscriptions; whichever matches the producer wins.
foreach (string expr in SubscriptionExpressions)
{
try
{
int subscribe = client.Subscribe(
szSubscription: expr,
wFromPri: 0, wToPri: short.MaxValue,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
Log($"Subscribe('{expr}') -> {subscribe}");
}
catch (Exception ex)
{
Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}");
}
}
LogProviders(client, "after Subscribe-multi");
// 3c. Pump for the configured duration. Log every message we see
// (filtered light to avoid noise from WM_PAINT / WM_TIMER /
// WM_GETICON spam from typical pumps). Poll GetStatistics on
// a tight cadence so any alarm transition is captured. Print
// "fire" / "clear" markers at fixed wallclock offsets so the
// operator can flip the trigger boolean during the run.
Log($"Probe running for {PumpDuration.TotalSeconds:F0}s. " +
$"Observing {TriggerTagReference} alarm transitions. " +
"External trigger expected from System Platform script (10s flip cadence).");
DateTime probeStart = DateTime.UtcNow;
DateTime deadline = probeStart + PumpDuration;
DateTime nextPoll = probeStart + PollInterval;
int pollCount = 0;
while (DateTime.UtcNow < deadline)
{
while (PeekMessage(out MSG msg, IntPtr.Zero, 0, 0, PM_REMOVE))
{
LogIfInteresting(msg);
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
// Trigger is supplied externally — a System Platform script
// flips TestMachine_001.TestAlarm001 every 10s. The probe
// observes only.
if (DateTime.UtcNow >= nextPoll)
{
PollGetStatistics(client, ++pollCount);
LogProviders(client, $"poll #{pollCount}");
PollAllChannels(client, pollCount);
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(10);
}
Log($"Pump duration {PumpDuration.TotalSeconds:F0}s elapsed; deregistering.");
Log($"GetHighPriAlarm tally: ok-with-record={getHighPriOk} threw={getHighPriThrow} " +
$"(throws indicate alarm-record marshaling failure; ok=empty record).");
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
}
finally
{
try { client?.Dispose(); } catch { /* swallow */ }
if (probeWindow != IntPtr.Zero)
{
DestroyWindow(probeWindow);
probeWindow = IntPtr.Zero;
}
if (registeredClass != null)
{
UnregisterClassW(registeredClass, GetModuleHandle(null!));
}
}
}
private string lastStatsSummary = string.Empty;
private string lastProvidersSummary = string.Empty;
private string lastHighPriSummary = string.Empty;
private string lastSfStatsSummary = string.Empty;
private int getHighPriOk = 0;
private int getHighPriThrow = 0;
/// <summary>
/// Try every read API the AlarmClient exposes and log when its
/// output changes. AlarmClient has at least three distinct read
/// surfaces — GetStatistics (current-change array), GetHighPriAlarm
/// (single-record peek), and the SF (stored filter) family — and any
/// of them might be the populated one.
/// </summary>
private static AlarmRecord NewAlarmRecord()
{
// The interop's auto-marshal flips DateTime fields to FILETIME on
// the way IN as well as OUT. default(DateTime) (year 1) is outside
// FILETIME's representable range, so initialize all DateTime fields
// to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler.
AlarmRecord rec = new AlarmRecord();
DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
foreach (var f in typeof(AlarmRecord).GetFields(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
{
if (f.FieldType == typeof(DateTime))
{
object boxed = rec;
f.SetValue(boxed, epoch);
rec = (AlarmRecord)boxed;
}
}
return rec;
}
private void PollAllChannels(AlarmClient client, int seq)
{
// Channel A: GetHighPriAlarm — peek highest-priority alarm. Track
// outcome state (record/empty/throw) and log every transition AND
// total counts at end. The throw correlates with an alarm being
// present (AVEVA fills timestamps with sentinel FILETIME values
// that crash the .NET marshaler) — useful as a presence signal
// even if we can't read the record.
try
{
AlarmRecord rec = NewAlarmRecord();
int rc = client.GetHighPriAlarm(ref rec);
string desc = rc == 0 ? DescribeAlarmRecord(rec) : "<no record>";
string summary = $"rc={rc} {desc}";
getHighPriOk++;
if (summary != lastHighPriSummary)
{
Log($"GetHighPriAlarm #{seq}: {summary} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
lastHighPriSummary = summary;
}
}
catch (Exception ex)
{
string es = $"{ex.GetType().Name}";
getHighPriThrow++;
if (es != lastHighPriSummary)
{
Log($"GetHighPriAlarm #{seq}: threw {es} (changed; ok={getHighPriOk}, throw={getHighPriThrow})");
lastHighPriSummary = es;
}
}
// Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly;
// populated alarms (if any) appear at low indices.
for (int idx = 0; idx <= 2; idx++)
{
try
{
AlarmRecord rec = NewAlarmRecord();
int rc = client.GetAlarmExtendedRec(idx, ref rec);
if (rc == 0)
{
string desc = DescribeAlarmRecord(rec);
Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}");
break; // log first present record only
}
}
catch (Exception ex)
{
if (idx == 0)
{
Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}");
}
break;
}
}
// Channel B: SF — snapshot + GetStatistics + iterate.
try
{
uint numAlarms = 0;
int sfCreate = client.SFCreateSnapshot(0, ref numAlarms);
int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0;
int sfStats = client.SFGetStatistics(
ref unackRet, ref unackAlm, ref ackAlm,
ref others, ref events, ref idxNewest);
string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " +
$"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " +
$"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}";
if (summary != lastSfStatsSummary)
{
Log($"SF channel #{seq}: {summary} (changed)");
lastSfStatsSummary = summary;
// If non-zero, fetch the first record by index via the
// standard GetAlarmExtendedRec — after SFCreateSnapshot the
// indices reference the snapshot.
if (numAlarms > 0)
{
AlarmRecord rec = new AlarmRecord();
int recRc = client.GetAlarmExtendedRec(0, ref rec);
Log($" GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}");
}
}
client.SFDeleteSnapshot();
}
catch (Exception ex)
{
Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}");
}
}
private void LogProviders(AlarmClient client, string when)
{
try
{
var providers = new System.Collections.Generic.List<string>();
int rc = client.GetProviders(providers);
string summary = $"count={providers.Count} list=[{string.Join(", ", providers)}]";
if (summary != lastProvidersSummary)
{
Log($"GetProviders [{when}] -> rc={rc} {summary} (changed)");
lastProvidersSummary = summary;
}
}
catch (Exception ex)
{
Log($"GetProviders [{when}] threw: {ex.GetType().Name}: {ex.Message}");
}
}
/// <summary>
/// Drive an MxAccess write to <see cref="TriggerTagReference"/> with the
/// supplied boolean value. Creates a fresh `LMXProxyServer` COM object,
/// registers, adds the item, writes the value, and tears down. Runs on
/// the same STA thread the probe uses for the AlarmClient — both COM
/// objects share the apartment, which matches the worker's runtime.
/// </summary>
private void TriggerWriteValue(bool value, int sequence)
{
object? lmx = null;
ILMXProxyServer? srv = null;
int handle = 0, itemHandle = 0;
try
{
lmx = new LMXProxyServerClass();
srv = (ILMXProxyServer)lmx;
handle = srv.Register($"AlarmProbe.Trigger.{sequence}");
Log($"Trigger write #{sequence}: Register -> handle={handle}");
itemHandle = srv.AddItem(handle, TriggerTagReference);
Log($"Trigger write #{sequence}: AddItem('{TriggerTagReference}') -> itemHandle={itemHandle}");
// First time only: dump every Write* method's signature so we know
// which to call. The first attempt hit TargetParameterCountException —
// the LMX server has multiple Write variants and we picked wrong.
if (sequence == 1)
{
Log($"Trigger write #{sequence}: enumerating Write* methods on {lmx.GetType().FullName}:");
foreach (var m in lmx.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance))
{
if (m.IsSpecialName) continue;
if (!m.Name.StartsWith("Write", StringComparison.OrdinalIgnoreCase)) continue;
string ps = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
Log($" {m.ReturnType.Name} {m.Name}({ps})");
}
}
// Late-bind Write — it isn't on ILMXProxyServer's interface but is
// exposed by the COM coclass.
object[] writeArgs = new object[] { handle, itemHandle, value };
object? rv = lmx.GetType().InvokeMember(
"Write",
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
binder: null, target: lmx, args: writeArgs);
Log($"Trigger write #{sequence}: Write({TriggerTagReference}={value}) -> rv={rv}");
}
catch (Exception ex)
{
Log($"Trigger write #{sequence}: FAILED: {ex.GetType().Name}: {ex.Message}");
if (ex.InnerException != null)
{
Log($" inner: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}");
}
}
finally
{
try
{
if (srv != null && itemHandle != 0) { srv.RemoveItem(handle, itemHandle); }
if (srv != null && handle != 0) { srv.Unregister(handle); }
}
catch (Exception ex)
{
Log($"Trigger write #{sequence}: cleanup failure: {ex.GetType().Name}: {ex.Message}");
}
if (lmx != null && System.Runtime.InteropServices.Marshal.IsComObject(lmx))
{
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(lmx); }
catch { /* swallow */ }
}
}
}
private void PollGetStatistics(AlarmClient client, int seq)
{
try
{
int percent = 0, total = 0, active = 0, suppressed = 0;
int suppressedFilters = 0, newAlarms = 0, changes = 0;
int[] codes = Array.Empty<int>();
int[] positions = Array.Empty<int>();
int[] handles = Array.Empty<int>();
int rc = client.GetStatistics(
ref percent, ref total, ref active, ref suppressed,
ref suppressedFilters, ref newAlarms, ref changes,
ref codes, ref positions, ref handles);
string codesStr = codes != null ? string.Join(",", codes) : "<null>";
string posStr = positions != null ? string.Join(",", positions) : "<null>";
string handlesStr = handles != null ? string.Join(",", handles) : "<null>";
int posLen = positions?.Length ?? 0;
// Suppress duplicate-summary spam — only log when interesting
// state-change is observed. The "interesting" digest excludes
// percent (always 100 at steady state).
string summary = $"total={total} active={active} suppressed={suppressed} " +
$"new={newAlarms} changes={changes} codes=[{codesStr}] " +
$"positions=[{posStr}] handles=[{handlesStr}]";
if (summary != lastStatsSummary)
{
Log($"GetStatistics #{seq} rc={rc} pct={percent} {summary} (changed)");
lastStatsSummary = summary;
}
// Always fetch records when positions has entries — records
// change content even when count stays the same.
if (posLen > 0 && positions != null)
{
for (int i = 0; i < Math.Min(posLen, 4); i++)
{
int idx = positions[i];
AlarmRecord rec = new AlarmRecord();
int recRc = client.GetAlarmExtendedRec(idx, ref rec);
Log($" GetAlarmExtendedRec(idx={idx}) rc={recRc} -> " +
DescribeAlarmRecord(rec));
}
}
}
catch (Exception ex)
{
Log($"GetStatistics #{seq} threw: {ex.GetType().Name}: {ex.Message}");
}
}
private static string DescribeAlarmRecord(AlarmRecord rec)
{
// Reflect over the record's public properties so we don't have to
// guess the field shape — the discovery probe already showed it has
// ar_AlarmName / ar_Provider / ar_Group / ar_AlmTransition / etc.
var sb = new System.Text.StringBuilder();
sb.Append("{ ");
bool first = true;
foreach (var prop in rec.GetType().GetProperties(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
{
try
{
object? v = prop.GetValue(rec);
string vs = v?.ToString() ?? "<null>";
if (vs.Length > 50) vs = vs.Substring(0, 47) + "...";
if (!first) sb.Append(", ");
sb.Append($"{prop.Name}={vs}");
first = false;
}
catch
{
// skip failing accessors
}
}
sb.Append(" }");
return sb.ToString();
}
private void LogIfInteresting(MSG m)
{
// Filter out the highest-volume noise (timer ticks, paint, mouse moves
// from a desktop session). Keep WM_USER..WM_APP+ entirely; those are
// the candidates for the AVEVA-registered message.
const uint WM_PAINT = 0x000F;
const uint WM_TIMER = 0x0113;
const uint WM_MOUSEMOVE = 0x0200;
const uint WM_NCMOUSEMOVE = 0x00A0;
if (m.message == WM_PAINT || m.message == WM_TIMER ||
m.message == WM_MOUSEMOVE || m.message == WM_NCMOUSEMOVE)
{
return;
}
string interpreted = InterpretMessageId(m.message);
Log(string.Format(
"WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8} hwnd=0x{4:X}",
m.message, interpreted,
m.wParam.ToInt64() & 0xFFFFFFFF, m.lParam.ToInt64() & 0xFFFFFFFF,
m.hwnd.ToInt64()));
}
private static string InterpretMessageId(uint id)
{
if (id < 0x0400) return "WM_<system>";
if (id < 0x8000) return $"WM_USER+0x{id - 0x0400:X4}";
if (id < 0xC000) return $"WM_APP+0x{id - 0x8000:X4}";
return $"RegisterWindowMessage_0x{id:X4}";
}
private IntPtr ProbeWndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
// Log every WM that lands on the probe window itself.
string interpreted = InterpretMessageId(msg);
Log(string.Format(
"WndProc WM 0x{0:X4} ({1}) wParam=0x{2:X8} lParam=0x{3:X8}",
msg, interpreted,
wParam.ToInt64() & 0xFFFFFFFF, lParam.ToInt64() & 0xFFFFFFFF));
return DefWindowProcW(hWnd, msg, wParam, lParam);
}
private void Log(string line)
{
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
public void Dispose()
{
if (wndProcHandle.IsAllocated) wndProcHandle.Free();
}
}
@@ -0,0 +1,276 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using Xunit.Abstractions;
namespace MxGateway.Worker.Tests;
/// <summary>
/// Live dev-rig smoke test for the alarms-over-gateway pipeline.
/// Exercises <see cref="WnWrapAlarmConsumer"/> + <see cref="AlarmDispatcher"/> +
/// <see cref="MxAccessAlarmEventSink"/> end-to-end against the actual
/// AVEVA System Platform install: subscribes to
/// <c>\\&lt;machine&gt;\Galaxy!DEV</c>, waits for at least one alarm
/// transition (the dev rig's flip script writes
/// <c>TestMachine_001.TestAlarm001</c> every 10s), drains the proto
/// <c>OnAlarmTransitionEvent</c> from the queue, then ack-by-name's
/// it and verifies the ack registers as a subsequent
/// <see cref="AlarmTransitionKind.Acknowledge"/> transition.
///
/// Skip-gated; flip <c>Skip=null</c> on the dev rig with the flip
/// script running.
/// </summary>
public sealed class AlarmsLiveSmokeTests
{
private static readonly string SubscriptionExpression =
$@"\\{Environment.MachineName}\Galaxy!DEV";
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(45);
private static readonly TimeSpan TransitionWaitTimeout = TimeSpan.FromSeconds(20);
private const string SessionId = "alarms-live-smoke";
private readonly ITestOutputHelper output;
private readonly Stopwatch elapsed = Stopwatch.StartNew();
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
public AlarmsLiveSmokeTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
public void Alarms_full_pipeline_round_trip()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try { RunSmoke(); }
catch (Exception ex) { threadException = ex; }
finally { done.Set(); }
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
done.Wait();
thread.Join();
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line))
{
output.WriteLine(line);
}
if (threadException != null)
{
throw threadException;
}
}
private void RunSmoke()
{
Log($"Subscription expression: {SubscriptionExpression}");
Log($"Pump duration: {PumpDuration.TotalSeconds:F0}s; transition wait timeout: {TransitionWaitTimeout.TotalSeconds:F0}s");
MxAccessEventQueue queue = new MxAccessEventQueue();
// pollIntervalMs=0 disables the internal Timer; we drive PollOnce
// manually from the STA below to avoid threadpool→STA marshaling
// (the wnwrap COM is ThreadingModel=Apartment, and this test
// doesn't run a Win32 message pump on its STA).
WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(
new WNWRAPCONSUMERLib.wwAlarmConsumerClass(),
pollIntervalMilliseconds: 0,
maxAlarmsPerFetch: 1024);
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
Log("Constructed consumer + sink + dispatcher.");
dispatcher.Subscribe(SubscriptionExpression);
Log("Subscribe -> ok. Driving PollOnce manually from this STA...");
// The wnwrap COM object is ThreadingModel=Apartment. The consumer's
// internal Timer would fire on a threadpool thread and deadlock on
// cross-apartment marshaling without a Win32 message pump. For the
// smoke test we constructed the consumer with pollIntervalMs=0
// (Timer disabled) and drive PollOnce manually here on the STA.
// Production hosting will route polls through the worker's
// StaRuntime in a follow-up PR.
// 1. Wait for the first transition (any kind), then keep waiting
// for one with kind=Raise so the alarm is currently Active when
// we try to ack. AVEVA rejects acks of cleared alarms with -55,
// so we have to time the ack against the flip script's 10s
// cadence.
OnAlarmTransitionEvent? raiseBody = null;
DateTime raiseDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
while (DateTime.UtcNow < raiseDeadline && raiseBody is null)
{
WorkerEvent? evt = WaitForTransition(queue, TransitionWaitTimeout, "raise", consumer);
if (evt is null) break;
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
Log("Transition: " + DescribeTransition(body));
Assert.Equal(SessionId, evt.Event.SessionId);
if (body.TransitionKind == AlarmTransitionKind.Raise)
{
raiseBody = body;
}
}
Assert.NotNull(raiseBody);
Assert.False(string.IsNullOrEmpty(raiseBody!.AlarmFullReference));
Assert.Contains("Galaxy", raiseBody.AlarmFullReference);
// 2. Snapshot the active set + verify the captured alarm is there.
var snapshot = dispatcher.SnapshotActiveAlarms();
Log($"SnapshotActiveAlarms count={snapshot.Count}");
foreach (var s in snapshot)
{
Log(" active: " + DescribeSnapshot(s));
}
Assert.NotEmpty(snapshot);
Assert.Contains(snapshot, s => s.AlarmFullReference == raiseBody.AlarmFullReference);
// 3. Ack-by-name using the captured reference. Parse the reference
// via the same convention the gateway dispatcher uses
// (Provider!Group.Tag where the tag may contain dots).
Assert.True(TryParseReference(
raiseBody.AlarmFullReference,
out string provider, out string group, out string alarmName),
$"Captured reference '{raiseBody.AlarmFullReference}' did not parse as Provider!Group.Tag.");
Log($"Ack target: provider='{provider}' group='{group}' name='{alarmName}'");
// Try the ack with real Windows identity. AVEVA's AlarmAckByName
// may reject synthetic operator strings; using the current process
// identity gives the alarm-history a recognizable principal.
string realUser = Environment.UserName;
string realNode = Environment.MachineName;
string realDomain = Environment.UserDomainName ?? string.Empty;
Log($"Ack identity: user='{realUser}' node='{realNode}' domain='{realDomain}'");
int rc = dispatcher.AcknowledgeByName(
alarmName: alarmName,
providerName: provider,
groupName: group,
ackComment: "alarms-live-smoke ack",
ackOperatorName: realUser,
ackOperatorNode: realNode,
ackOperatorDomain: realDomain,
ackOperatorFullName: realUser);
Log($"AcknowledgeByName(real identity) -> rc={rc}");
Assert.Equal(0, rc);
// 4. Wait for the post-ack transition. With the alarm flipping every
// 10s and the consumer polling every 500ms, the next state
// change should be either kind=Acknowledge (the ack we just
// sent registered as a state delta UnackAlm → AckAlm) or the
// flip script's next Clear (UnackAlm → UnackRtn).
WorkerEvent? second = WaitForTransition(queue, TransitionWaitTimeout, "post-ack", consumer);
Assert.NotNull(second);
OnAlarmTransitionEvent secondBody = second!.Event.OnAlarmTransition;
Log("Post-ack transition: " + DescribeTransition(secondBody));
Assert.NotEqual(AlarmTransitionKind.Unspecified, secondBody.TransitionKind);
// 5. Pump a little longer to confirm the consumer keeps reporting
// transitions on the 10s flip cadence.
DateTime deadline = DateTime.UtcNow + PumpDuration;
int additional = 0;
while (DateTime.UtcNow < deadline)
{
consumer.PollOnce();
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
{
additional++;
OnAlarmTransitionEvent body = evt.Event.OnAlarmTransition;
Log($" +{additional}: " + DescribeTransition(body));
}
Thread.Sleep(500);
}
Log($"Pump completed; additional transitions captured: {additional}.");
}
private WorkerEvent? WaitForTransition(
MxAccessEventQueue queue,
TimeSpan timeout,
string label,
WnWrapAlarmConsumer consumer)
{
DateTime deadline = DateTime.UtcNow + timeout;
int pollCount = 0;
while (DateTime.UtcNow < deadline)
{
try
{
consumer.PollOnce();
pollCount++;
if (pollCount == 1) Log("First PollOnce returned without throw.");
}
catch (Exception ex)
{
Log($"PollOnce threw on poll #{pollCount + 1}: {ex.GetType().Name}: {ex.Message}");
if (ex is System.Runtime.InteropServices.COMException ce)
{
Log($" HResult=0x{(uint)ce.HResult:X8}");
}
throw;
}
if (queue.TryDequeue(out WorkerEvent? evt) && evt is not null)
{
if (evt.Event.Family == MxEventFamily.OnAlarmTransition)
{
return evt;
}
Log($"Skipped non-alarm event (family={evt.Event.Family}) while waiting for {label}.");
}
Thread.Sleep(500);
}
Log($"Timed out waiting for {label} transition after {timeout.TotalSeconds:F0}s (poll count={pollCount}).");
return null;
}
private static bool TryParseReference(
string reference,
out string provider,
out string group,
out string alarmName)
{
provider = group = alarmName = string.Empty;
if (string.IsNullOrWhiteSpace(reference)) return false;
int bang = reference.IndexOf('!');
if (bang <= 0 || bang == reference.Length - 1) return false;
string left = reference.Substring(0, bang);
string right = reference.Substring(bang + 1);
int dot = right.IndexOf('.');
if (dot <= 0 || dot == right.Length - 1) return false;
provider = left;
group = right.Substring(0, dot);
alarmName = right.Substring(dot + 1);
return true;
}
private static string DescribeTransition(OnAlarmTransitionEvent body)
{
return string.Format(
"kind={0} ref='{1}' source='{2}' type='{3}' severity={4} operator='{5}' comment='{6}' ts={7:o}",
body.TransitionKind, body.AlarmFullReference, body.SourceObjectReference,
body.AlarmTypeName, body.Severity, body.OperatorUser, body.OperatorComment,
body.TransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
}
private static string DescribeSnapshot(ActiveAlarmSnapshot s)
{
return string.Format(
"ref='{0}' state={1} severity={2} operator='{3}' comment='{4}' ts={5:o}",
s.AlarmFullReference, s.CurrentState, s.Severity, s.OperatorUser,
s.OperatorComment,
s.LastTransitionTimestamp?.ToDateTime() ?? DateTime.MinValue);
}
private void Log(string line)
{
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
}
@@ -0,0 +1,452 @@
using System;
using System.Collections.Generic;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Verifies that the four new alarm <see cref="MxCommandKind"/> values
/// route through <see cref="MxAccessCommandExecutor"/> to a fake
/// <see cref="IAlarmCommandHandler"/> and that the resulting
/// <see cref="MxCommandReply"/> carries the expected payload.
///
/// The data-side <see cref="MxAccessSession"/> is constructed via a
/// no-op factory because the executor only touches it for non-alarm
/// command kinds — alarm dispatch never reaches the data session.
/// </summary>
public sealed class AlarmCommandExecutorTests
{
private const string SessionId = "S";
private const string CorrelationId = "C";
[Fact]
public void SubscribeAlarms_routes_to_handler_and_returns_ok()
{
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(@"\\HOST\Galaxy!Area", handler.LastSubscription);
Assert.Equal(SessionId, handler.LastSessionId);
}
[Fact]
public void SubscribeAlarms_without_handler_returns_invalid_request()
{
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void SubscribeAlarms_with_empty_expression_returns_invalid_request()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.SubscribeAlarms,
SubscribeAlarms = new SubscribeAlarmsCommand
{
SubscriptionExpression = " ",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void AcknowledgeAlarm_routes_native_status_into_hresult_and_payload()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
MxAccessCommandExecutor executor = NewExecutor(handler);
Guid g = Guid.NewGuid();
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = g.ToString(),
Comment = "ack",
OperatorUser = "alice",
OperatorNode = "WS",
OperatorDomain = "CORP",
OperatorFullName = "Alice S",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(0, reply.Hresult);
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.Equal(g, handler.LastAckGuid);
Assert.Equal("alice", handler.LastAckOperatorName);
}
[Fact]
public void AcknowledgeAlarm_with_invalid_guid_returns_invalid_request()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = "not-a-guid",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void AcknowledgeAlarm_with_nonzero_native_status_carries_diagnostic()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = -123 };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(-123, reply.Hresult);
Assert.Contains("-123", reply.DiagnosticMessage);
}
[Fact]
public void AcknowledgeAlarmByName_routes_tuple_to_handler()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeReturn = 0 };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = "TestMachine_001.TestAlarm001",
ProviderName = "Galaxy",
GroupName = "TestArea",
Comment = "ack",
OperatorUser = "alice",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.NotNull(reply.AcknowledgeAlarm);
Assert.Equal(0, reply.AcknowledgeAlarm.NativeStatus);
Assert.NotNull(handler.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", handler.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", handler.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", handler.LastAckByNameTuple!.Value.Group);
Assert.Equal("alice", handler.LastAckOperatorName);
}
[Fact]
public void AcknowledgeAlarmByName_with_empty_name_returns_invalid_request()
{
MxAccessCommandExecutor executor = NewExecutor(new FakeAlarmHandler());
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarmByName,
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
{
AlarmName = " ",
ProviderName = "Galaxy",
GroupName = "TestArea",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
}
[Fact]
public void QueryActiveAlarms_returns_payload_with_snapshots()
{
FakeAlarmHandler handler = new FakeAlarmHandler
{
QueryResult = new[]
{
new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T1" },
new ActiveAlarmSnapshot { AlarmFullReference = "Galaxy!A.T2" },
},
};
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.QueryActiveAlarms,
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand
{
AlarmFilterPrefix = "Galaxy!A",
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.NotNull(reply.QueryActiveAlarms);
Assert.Equal(2, reply.QueryActiveAlarms.Snapshots.Count);
Assert.Equal("Galaxy!A", handler.LastFilterPrefix);
}
[Fact]
public void UnsubscribeAlarms_routes_to_handler()
{
FakeAlarmHandler handler = new FakeAlarmHandler();
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.UnsubscribeAlarms,
UnsubscribeAlarms = new UnsubscribeAlarmsCommand(),
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(handler.UnsubscribeCalled);
}
[Fact]
public void UnsubscribeAlarms_without_handler_is_ok_noop()
{
MxAccessCommandExecutor executor = NewExecutor(alarmHandler: null);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.UnsubscribeAlarms,
UnsubscribeAlarms = new UnsubscribeAlarmsCommand(),
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
[Fact]
public void Acknowledge_handler_throw_returns_mxaccess_failure()
{
FakeAlarmHandler handler = new FakeAlarmHandler { AcknowledgeThrow = true };
MxAccessCommandExecutor executor = NewExecutor(handler);
StaCommand command = new StaCommand(
SessionId, CorrelationId,
new MxCommand
{
Kind = MxCommandKind.AcknowledgeAlarm,
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
{
AlarmGuid = Guid.NewGuid().ToString(),
},
});
MxCommandReply reply = executor.Execute(command);
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.Contains("simulated", reply.DiagnosticMessage);
}
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
{
// Construct an executor with a no-op data session — we only exercise
// the alarm switch arms, which never touch the data session.
return new MxAccessCommandExecutor(
session: NoopMxAccessSession.Create(),
variantConverter: new MxGateway.Worker.Conversion.VariantConverter(),
alarmCommandHandler: alarmHandler);
}
/// <summary>
/// Reflection-based helper to construct an MxAccessSession without
/// a real COM object. Only the alarm-side code paths are exercised
/// in this test class, so the session reference is never
/// dereferenced.
/// </summary>
private static class NoopMxAccessSession
{
public static MxAccessSession Create()
{
// Walk to the private constructor via reflection — the public
// factory MxAccessSession.Create(...) requires a real COM object.
System.Reflection.ConstructorInfo? ctor = typeof(MxAccessSession)
.GetConstructor(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
binder: null,
types: new[]
{
typeof(object),
typeof(IMxAccessServer),
typeof(IMxAccessEventSink),
typeof(MxAccessHandleRegistry),
typeof(int),
},
modifiers: null);
if (ctor is null)
{
throw new InvalidOperationException(
"MxAccessSession private ctor signature changed; update the test seam.");
}
return (MxAccessSession)ctor.Invoke(new object[]
{
new object(),
new NullMxAccessServer(),
new NullEventSink(),
new MxAccessHandleRegistry(),
System.Environment.CurrentManagedThreadId,
});
}
}
private sealed class NullMxAccessServer : IMxAccessServer
{
public int Register(string clientName) => 0;
public void Unregister(int serverHandle) { }
public int AddItem(int serverHandle, string itemDefinition) => 0;
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0;
public void RemoveItem(int serverHandle, int itemHandle) { }
public void Advise(int serverHandle, int itemHandle) { }
public void UnAdvise(int serverHandle, int itemHandle) { }
public void AdviseSupervisory(int serverHandle, int itemHandle) { }
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0;
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) { }
public void Suspend(int serverHandle, int itemHandle) { }
public void Activate(int serverHandle, int itemHandle) { }
public void Write(int serverHandle, int itemHandle, object value, int userId) { }
public void Write2(int serverHandle, int itemHandle, object value, object timestampValue, int userId) { }
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value) { }
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object value, object timestampValue) { }
public int AuthenticateUser(string userName, string password) => 0;
public int ArchestrAUserToId(string userName) => 0;
}
private sealed class NullEventSink : IMxAccessEventSink
{
public void Attach(object mxAccessComObject, string sessionId) { }
public void Detach() { }
}
private sealed class FakeAlarmHandler : IAlarmCommandHandler
{
public string? LastSubscription { get; private set; }
public string? LastSessionId { get; private set; }
public bool UnsubscribeCalled { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckOperatorName { get; private set; }
public int AcknowledgeReturn { get; set; }
public bool AcknowledgeThrow { get; set; }
public IReadOnlyList<ActiveAlarmSnapshot> QueryResult { get; set; } =
Array.Empty<ActiveAlarmSnapshot>();
public string? LastFilterPrefix { get; private set; }
public void Subscribe(string subscription, string sessionId)
{
LastSubscription = subscription;
LastSessionId = sessionId;
}
public void Unsubscribe()
{
UnsubscribeCalled = true;
}
public int Acknowledge(
Guid alarmGuid, string comment, string operatorUser,
string operatorNode, string operatorDomain, string operatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = operatorUser;
if (AcknowledgeThrow)
{
throw new InvalidOperationException("simulated alarm-handler failure");
}
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string comment, string operatorUser, string operatorNode,
string operatorDomain, string operatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = operatorUser;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
{
LastFilterPrefix = alarmFilterPrefix;
return QueryResult;
}
public void Dispose() { }
}
}
@@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for the per-session alarm command router. Uses a fake
/// consumer factory so the lazy-construction lifecycle on
/// <c>SubscribeAlarms</c> is exercised without touching wnwrap COM.
/// </summary>
public sealed class AlarmCommandHandlerTests
{
[Fact]
public void Subscribe_creates_consumer_and_calls_subscribe()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!Area", "session-1");
Assert.True(handler.IsSubscribed);
Assert.Equal(@"\\HOST\Galaxy!Area", consumer.LastSubscription);
}
[Fact]
public void Second_subscribe_without_unsubscribe_throws()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!B", "s1"));
}
[Fact]
public void Subscribe_disposes_consumer_when_underlying_subscribe_throws()
{
FakeConsumer consumer = new FakeConsumer { ThrowOnSubscribe = true };
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
}
[Fact]
public void Unsubscribe_disposes_consumer_and_clears_state()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Unsubscribe();
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
}
[Fact]
public void Unsubscribe_without_prior_subscribe_is_noop()
{
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer());
handler.Unsubscribe(); // Should not throw.
Assert.False(handler.IsSubscribed);
}
[Fact]
public void Acknowledge_forwards_to_consumer_with_full_operator_identity()
{
FakeConsumer consumer = new FakeConsumer { AcknowledgeReturn = 0 };
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
Guid g = Guid.NewGuid();
int rc = handler.Acknowledge(g, "c", "u", "n", "d", "F");
Assert.Equal(0, rc);
Assert.Equal(g, consumer.LastAckGuid);
Assert.Equal("u", consumer.LastAckOperatorName);
}
[Fact]
public void Acknowledge_before_subscribe_throws_invalid_op()
{
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => new FakeConsumer());
Assert.Throws<InvalidOperationException>(
() => handler.Acknowledge(Guid.Empty, "", "", "", "", ""));
}
[Fact]
public void QueryActive_returns_mapped_proto_snapshots()
{
FakeConsumer consumer = new FakeConsumer
{
SnapshotResult = new[]
{
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag1",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
},
},
};
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
IReadOnlyList<ActiveAlarmSnapshot> snapshots = handler.QueryActive(null);
Assert.Single(snapshots);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
}
[Fact]
public void QueryActive_filters_by_prefix()
{
FakeConsumer consumer = new FakeConsumer
{
SnapshotResult = new[]
{
NewRecord("Galaxy", "AreaA", "Tag1"),
NewRecord("Galaxy", "AreaB", "Tag2"),
},
};
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
IReadOnlyList<ActiveAlarmSnapshot> filtered = handler.QueryActive("Galaxy!AreaA");
Assert.Single(filtered);
Assert.Equal("Galaxy!AreaA.Tag1", filtered[0].AlarmFullReference);
}
[Fact]
public void Dispose_unsubscribes_and_disposes_consumer()
{
FakeConsumer consumer = new FakeConsumer();
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => consumer);
handler.Subscribe(@"\\HOST\Galaxy!A", "s1");
handler.Dispose();
Assert.True(consumer.Disposed);
Assert.Throws<ObjectDisposedException>(
() => handler.Subscribe("x", "y"));
}
private static MxAlarmSnapshotRecord NewRecord(string provider, string group, string tag)
{
return new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = provider,
Group = group,
TagName = tag,
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
};
}
private sealed class FakeConsumer : IMxAccessAlarmConsumer
{
#pragma warning disable CS0067 // Event never invoked — fake; AlarmCommandHandler tests don't drive transitions.
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
#pragma warning restore CS0067
public string? LastSubscription { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckOperatorName { get; private set; }
public int AcknowledgeReturn { get; set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
public bool ThrowOnSubscribe { get; set; }
public bool Disposed { get; private set; }
public void Subscribe(string subscription)
{
LastSubscription = subscription;
if (ThrowOnSubscribe)
{
throw new InvalidOperationException("simulated wnwrap subscribe failure");
}
}
public int AcknowledgeByGuid(
Guid alarmGuid, string ackComment, string ackOperatorName,
string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName)
{
LastAckGuid = alarmGuid;
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
public void Dispose()
{
Disposed = true;
}
}
}
@@ -0,0 +1,326 @@
using System;
using System.Collections.Generic;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for the in-process A.3 dispatcher: prove that
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> events
/// fan out to the worker's <see cref="MxAccessEventQueue"/> as proto
/// <see cref="OnAlarmTransitionEvent"/> messages with correctly mapped
/// fields. The fake consumer below stands in for the wnwrap-backed
/// production implementation so this exercise needs no AVEVA install.
/// </summary>
public sealed class AlarmDispatcherTests
{
private const string SessionId = "session-001";
[Fact]
public void TransitionEvent_lands_in_queue_with_mapped_fields()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.Unspecified,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "TestMachine_001.TestAlarm001",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
TransitionTimestampUtc = ts,
AlarmComment = "Test alarm #1",
},
});
Assert.Equal(1, queue.Count);
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
Assert.NotNull(workerEvent);
MxEvent mxEvent = workerEvent!.Event;
Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family);
Assert.Equal(SessionId, mxEvent.SessionId);
OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition;
Assert.NotNull(body);
Assert.Equal("Galaxy!TestArea.TestMachine_001.TestAlarm001", body.AlarmFullReference);
Assert.Equal("TestMachine_001.TestAlarm001", body.SourceObjectReference);
Assert.Equal("DSC", body.AlarmTypeName);
Assert.Equal(AlarmTransitionKind.Raise, body.TransitionKind);
Assert.Equal(500, body.Severity);
Assert.Equal("Test alarm #1", body.OperatorComment);
Assert.Equal("TestArea", body.Category);
Assert.NotNull(body.TransitionTimestamp);
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
}
[Fact]
public void Consecutive_unchanged_state_does_not_emit_a_transition()
{
// Mapper.MapTransition returns Unspecified when the state didn't
// change; the dispatcher should drop the event before queueing.
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.UnackAlm,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "X",
TagName = "Y",
State = MxAlarmStateKind.UnackAlm,
},
});
Assert.Equal(0, queue.Count);
}
[Theory]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
public void Transition_kind_follows_state_table(
MxAlarmStateKind previous,
MxAlarmStateKind current,
AlarmTransitionKind expected)
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
using AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = previous,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "G",
TagName = "T",
State = current,
},
});
Assert.Equal(1, queue.Count);
queue.TryDequeue(out WorkerEvent? evt);
Assert.Equal(expected, evt!.Event.OnAlarmTransition.TransitionKind);
}
[Fact]
public void Subscribe_forwards_to_consumer()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
dispatcher.Subscribe(@"\\HOST\Galaxy!Area1");
Assert.Equal(@"\\HOST\Galaxy!Area1", consumer.LastSubscription);
}
[Fact]
public void Acknowledge_forwards_to_consumer_with_full_operator_identity()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
consumer.AcknowledgeReturn = 0;
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
Guid guid = Guid.NewGuid();
int rc = dispatcher.Acknowledge(
guid, "Acked", "alice", "WS01", "CORP", "Alice Smith");
Assert.Equal(0, rc);
Assert.Equal(guid, consumer.LastAckGuid);
Assert.Equal("Acked", consumer.LastAckComment);
Assert.Equal("alice", consumer.LastAckOperatorName);
Assert.Equal("WS01", consumer.LastAckOperatorNode);
Assert.Equal("CORP", consumer.LastAckOperatorDomain);
Assert.Equal("Alice Smith", consumer.LastAckOperatorFullName);
}
[Fact]
public void AcknowledgeByName_forwards_to_consumer_with_full_tuple()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AcknowledgeReturn = 0 };
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
int rc = dispatcher.AcknowledgeByName(
alarmName: "TestMachine_001.TestAlarm001",
providerName: "Galaxy",
groupName: "TestArea",
ackComment: "ack",
ackOperatorName: "alice",
ackOperatorNode: "WS",
ackOperatorDomain: "CORP",
ackOperatorFullName: "Alice Smith");
Assert.Equal(0, rc);
Assert.NotNull(consumer.LastAckByNameTuple);
Assert.Equal("TestMachine_001.TestAlarm001", consumer.LastAckByNameTuple!.Value.Name);
Assert.Equal("Galaxy", consumer.LastAckByNameTuple!.Value.Provider);
Assert.Equal("TestArea", consumer.LastAckByNameTuple!.Value.Group);
}
[Fact]
public void SnapshotActiveAlarms_maps_records_to_protos()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
consumer.SnapshotResult = new[]
{
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag1",
Type = "DSC",
Priority = 500,
State = MxAlarmStateKind.UnackAlm,
TransitionTimestampUtc = ts,
AlarmComment = "x",
},
new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "TestArea",
TagName = "Tag2",
Type = "ANL",
Priority = 100,
State = MxAlarmStateKind.AckAlm,
TransitionTimestampUtc = ts,
},
};
using AlarmDispatcher dispatcher = new AlarmDispatcher(
consumer,
new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()),
SessionId);
IReadOnlyList<ActiveAlarmSnapshot> snapshots = dispatcher.SnapshotActiveAlarms();
Assert.Equal(2, snapshots.Count);
Assert.Equal("Galaxy!TestArea.Tag1", snapshots[0].AlarmFullReference);
Assert.Equal(AlarmConditionState.Active, snapshots[0].CurrentState);
Assert.Equal(500, snapshots[0].Severity);
Assert.Equal(ts, snapshots[0].LastTransitionTimestamp.ToDateTime());
Assert.Equal("Galaxy!TestArea.Tag2", snapshots[1].AlarmFullReference);
Assert.Equal(AlarmConditionState.ActiveAcked, snapshots[1].CurrentState);
}
[Fact]
public void Dispose_unsubscribes_handler_and_disposes_consumer()
{
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
MxAccessEventQueue queue = new MxAccessEventQueue();
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
AlarmDispatcher dispatcher = new AlarmDispatcher(consumer, sink, SessionId);
dispatcher.Dispose();
Assert.True(consumer.Disposed);
consumer.RaiseTransition(new MxAlarmTransitionEvent
{
PreviousState = MxAlarmStateKind.Unspecified,
Record = new MxAlarmSnapshotRecord
{
AlarmGuid = Guid.NewGuid(),
ProviderName = "Galaxy",
Group = "G",
TagName = "T",
State = MxAlarmStateKind.UnackAlm,
},
});
Assert.Equal(0, queue.Count);
}
private sealed class FakeAlarmConsumer : IMxAccessAlarmConsumer
{
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
public string? LastSubscription { get; private set; }
public Guid LastAckGuid { get; private set; }
public string? LastAckComment { get; private set; }
public string? LastAckOperatorName { get; private set; }
public string? LastAckOperatorNode { get; private set; }
public string? LastAckOperatorDomain { get; private set; }
public string? LastAckOperatorFullName { get; private set; }
public int AcknowledgeReturn { get; set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotResult { get; set; } =
Array.Empty<MxAlarmSnapshotRecord>();
public bool Disposed { get; private set; }
public void RaiseTransition(MxAlarmTransitionEvent transition)
{
AlarmTransitionEmitted?.Invoke(this, transition);
}
public void Subscribe(string subscription)
{
LastSubscription = subscription;
}
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
LastAckGuid = alarmGuid;
LastAckComment = ackComment;
LastAckOperatorName = ackOperatorName;
LastAckOperatorNode = ackOperatorNode;
LastAckOperatorDomain = ackOperatorDomain;
LastAckOperatorFullName = ackOperatorFullName;
return AcknowledgeReturn;
}
public int AcknowledgeByName(
string alarmName, string providerName, string groupName,
string ackComment, string ackOperatorName, string ackOperatorNode,
string ackOperatorDomain, string ackOperatorFullName)
{
LastAckByNameTuple = (alarmName, providerName, groupName);
LastAckOperatorName = ackOperatorName;
return AcknowledgeReturn;
}
public (string Name, string Provider, string Group)? LastAckByNameTuple { get; private set; }
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
return SnapshotResult;
}
public void Dispose()
{
Disposed = true;
}
}
}
@@ -1,19 +1,19 @@
using System;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess; using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess; namespace MxGateway.Worker.Tests.MxAccess;
/// <summary> /// <summary>
/// PR A.5 — pins the reference-composition logic used to translate AVEVA /// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML
/// AlarmRecord events into proto-friendly fields. Transition-kind mapping /// payloads into proto-friendly fields. The COM-side I/O on
/// (a trivial 4-line switch over <c>eAlmTransitions</c>) is verified on /// <see cref="WnWrapAlarmConsumer"/> needs an AVEVA install and is
/// the dev rig as part of the live alarm-event smoke test rather than /// covered by the Skip-gated probe (<c>WnWrapConsumerProbeTests</c>);
/// as a unit test, because the AVEVA-licensed enum assembly is /// these unit tests cover everything that doesn't touch the live COM
/// <c>Private=false</c> on the reference and is not copied to the test /// surface.
/// bin directory.
/// </summary> /// </summary>
public sealed class AlarmRecordTransitionMapperTests public sealed class AlarmRecordTransitionMapperTests
{ {
[Fact] [Fact]
public void ComposeFullReference_uses_provider_bang_group_dot_name_format() public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
{ {
@@ -47,4 +47,76 @@ public sealed class AlarmRecordTransitionMapperTests
providerName: null, groupName: null, alarmName: "Bare"); providerName: null, groupName: null, alarmName: "Bare");
Assert.Equal("Bare", reference); Assert.Equal("Bare", reference);
} }
[Theory]
[InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)]
[InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)]
[InlineData("UNACK_RTN", MxAlarmStateKind.UnackRtn)]
[InlineData("ACK_RTN", MxAlarmStateKind.AckRtn)]
[InlineData("unack_alm", MxAlarmStateKind.UnackAlm)] // case-insensitive
[InlineData(" ACK_ALM ", MxAlarmStateKind.AckAlm)] // trim
[InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)]
[InlineData("", MxAlarmStateKind.Unspecified)]
[InlineData(null, MxAlarmStateKind.Unspecified)]
public void ParseStateKind_decodes_state_strings(string? input, MxAlarmStateKind expected)
{
Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input));
}
[Theory]
// First sighting: new alarm in *_ALM → Raise.
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Raise)]
// First sighting in *_RTN → Clear (unusual; missed the original raise).
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
// Active → Cleared.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
[InlineData(MxAlarmStateKind.AckAlm, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
// Cleared → Active (re-trigger).
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
[InlineData(MxAlarmStateKind.AckRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
// Unacked → Acked (operator ack).
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Acknowledge)]
// No-op (state unchanged) — caller is supposed to filter these out.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)]
// Current=Unspecified → Unspecified.
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)]
public void MapTransition_decides_proto_kind(
MxAlarmStateKind previous,
MxAlarmStateKind current,
AlarmTransitionKind expected)
{
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
}
[Fact]
public void ParseTransitionTimestampUtc_assembles_utc_from_xml_fields()
{
// Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0.
// Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC.
DateTime utc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
"2026/5/1", "13:26:14.709", gmtOffsetMinutes: 240, dstAdjustMinutes: 0);
Assert.Equal(DateTimeKind.Utc, utc.Kind);
Assert.Equal(2026, utc.Year);
Assert.Equal(5, utc.Month);
Assert.Equal(1, utc.Day);
Assert.Equal(17, utc.Hour);
Assert.Equal(26, utc.Minute);
Assert.Equal(14, utc.Second);
Assert.Equal(709, utc.Millisecond);
}
[Fact]
public void ParseTransitionTimestampUtc_returns_min_value_on_unparseable_inputs()
{
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0));
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("not a date", "13:00:00", 0, 0));
Assert.Equal(DateTime.MinValue,
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("2026/5/1", "not a time", 0, 0));
}
} }
@@ -0,0 +1,112 @@
using System;
using System.Linq;
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
/// parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
/// dictionary, and the 32-char-hex GUID round-trip. The COM-side
/// polling loop is verified separately by the Skip-gated
/// <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
/// </summary>
public sealed class WnWrapAlarmConsumerXmlTests
{
/// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
private const string SingleAlarmActiveXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
"<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
"<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
"<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
"<GROUP>TestArea</GROUP>" +
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
"<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
"<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
"<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
"<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
"</ALARM_RECORDS>";
private const string EmptyXml =
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
[Fact]
public void ParseSnapshotXml_returns_empty_dictionary_for_empty_payload()
{
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
Assert.Empty(records);
}
[Fact]
public void ParseSnapshotXml_returns_empty_dictionary_for_null_or_whitespace()
{
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
}
[Fact]
public void ParseSnapshotXml_decodes_single_active_alarm_record()
{
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
Assert.Single(records);
Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
var record = records[expectedGuid];
Assert.Equal(expectedGuid, record.AlarmGuid);
Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
Assert.Equal("Galaxy", record.ProviderName);
Assert.Equal("TestArea", record.Group);
Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
Assert.Equal("DSC", record.Type);
Assert.Equal("true", record.Value);
Assert.Equal("true", record.Limit);
Assert.Equal(500, record.Priority);
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
Assert.Equal("Test alarm #1", record.AlarmComment);
Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
// 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
Assert.Equal(17, record.TransitionTimestampUtc.Hour);
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
}
[Fact]
public void ParseSnapshotXml_silently_drops_records_with_invalid_guids()
{
string xml = SingleAlarmActiveXml.Replace(
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
"<GUID>not-a-guid</GUID>");
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
}
[Theory]
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
public void TryParseHexGuid_handles_dashless_32_char_hex(string hex, string expected)
{
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
Assert.Equal(new Guid(expected), guid);
}
[Theory]
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
public void TryParseHexGuid_accepts_canonical_dashed_form(string canonical)
{
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
Assert.Equal(new Guid(canonical), guid);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("nope")]
[InlineData("0123456789ABCDEF")] // too short
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
public void TryParseHexGuid_rejects_invalid_input(string? hex)
{
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
Assert.Equal(Guid.Empty, guid);
}
}
@@ -25,4 +25,28 @@
<ProjectReference Include="..\MxGateway.Worker\MxGateway.Worker.csproj" /> <ProjectReference Include="..\MxGateway.Worker\MxGateway.Worker.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MxAccess">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="aaAlarmManagedClient">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="IAlarmMgrDataProvider">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="Interop.WNWRAPCONSUMERLib">
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,287 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using WNWRAPCONSUMERLib;
using Xunit.Abstractions;
namespace MxGateway.Worker.Tests;
/// <summary>
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
/// subscribe to the dev rig's `\\&lt;machine&gt;\Galaxy!DEV` provider, and
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
/// the FILETIME→DateTime auto-marshaling that crashes
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
///
/// Skip-gated; flip Skip=null to run on the dev rig.
/// </summary>
public sealed class WnWrapConsumerProbeTests
{
private static readonly string MachineName = Environment.MachineName;
private static readonly string SubscriptionExpression =
$@"\\{MachineName}\Galaxy!DEV";
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
private static readonly string XmlAlarmQuery =
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
"<QUERY>" +
$"<NODE>{Environment.MachineName}</NODE>" +
"<PROVIDER>Galaxy</PROVIDER>" +
"<GROUP>DEV</GROUP>" +
"</QUERY>" +
"</QUERIES>";
private const int MaxAlarmsPerFetch = 100;
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
private readonly ITestOutputHelper output;
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private readonly Stopwatch elapsed = Stopwatch.StartNew();
public WnWrapConsumerProbeTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
public void ProbeWnWrapConsumer()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try { RunProbe(); }
catch (Exception ex) { threadException = ex; }
finally { done.Set(); }
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
done.Wait();
thread.Join();
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line))
{
output.WriteLine(line);
}
if (threadException != null)
{
throw threadException;
}
}
private void RunProbe()
{
wwAlarmConsumerClass? client = null;
try
{
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
client = new wwAlarmConsumerClass();
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
// MUST precede RegisterConsumer for the alarm provider to become
// visible. The wnwrap surface mirrors that requirement.
try
{
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
Log($"InitializeConsumer -> {init}");
}
catch (Exception ex)
{
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
// hWnd=0 — XML pull-based; no message pump needed.
int reg = client.RegisterConsumer(
hWnd: 0,
szProductName: "MxGatewayProbe",
szApplicationName: "MxGatewayProbe.WnWrap",
szVersion: "1.0");
Log($"RegisterConsumer(hWnd=0) -> {reg}");
}
catch (Exception ex)
{
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
// Try both subscription mechanisms: classic Subscribe (canonical
// scope from prior aaAlarmManagedClient probe), and
// SetXmlAlarmQuery (the wnwrap-native filter format).
try
{
int sub = client.Subscribe(
szSubscription: SubscriptionExpression,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
}
catch (Exception ex)
{
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
client.SetXmlAlarmQuery(XmlAlarmQuery);
Log("SetXmlAlarmQuery -> ok");
}
catch (Exception ex)
{
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Echo the query back so we can confirm what the consumer is
// actually filtering on (provider may rewrite or reject some
// attributes silently).
try
{
object echo = string.Empty;
client.GetXmlAlarmQuery(out echo);
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
}
catch (Exception ex)
{
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
// every change in payload. Run for PumpDuration. The user's flip
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
// least 2-3 transitions over a 30s window.
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
DateTime deadline = DateTime.UtcNow + PumpDuration;
DateTime nextPoll = DateTime.UtcNow;
int pollCount = 0;
string lastV2 = string.Empty;
string lastV1 = string.Empty;
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
int statsOk = 0, statsThrow = 0;
string lastStats = string.Empty;
while (DateTime.UtcNow < deadline)
{
if (DateTime.UtcNow >= nextPoll)
{
pollCount++;
// V2 channel.
try
{
object xml2 = string.Empty;
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
v2Ok++;
string s = xml2?.ToString() ?? "<null>";
if (s != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV2 = s;
}
}
catch (Exception ex)
{
v2Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
lastV2 = es;
}
}
// V1 channel — different vtable slot; either may be the
// populated one in this AVEVA build.
try
{
object xml1 = string.Empty;
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
v1Ok++;
string s = xml1?.ToString() ?? "<null>";
if (s != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV1 = s;
}
}
catch (Exception ex)
{
v1Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
lastV1 = es;
}
}
// Stats channel — heartbeat + active-count even if the XML
// calls are dry, this surfaces whether wnwrap sees any
// alarms in the subscribed scope at all.
try
{
int pct, total, active, newAlms, changes;
client.GetStatistics(
out pct, out total, out active, out newAlms, out changes,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
statsOk++;
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
if (statsSummary != lastStats)
{
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
lastStats = statsSummary;
}
}
catch (Exception ex)
{
statsThrow++;
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
}
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(20);
}
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
}
finally
{
if (client != null && Marshal.IsComObject(client))
{
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
}
}
}
private void Log(string line)
{
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
}
}
@@ -1,172 +0,0 @@
using System;
using System.Collections.Generic;
using AlarmMgrDataProviderCOM;
using aaAlarmManagedClient;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — production <see cref="IMxAccessAlarmConsumer"/> backed by
/// <c>aaAlarmManagedClient.AlarmClient</c>. Forwards
/// <c>GetAlarmChangesCompleted</c> events into the worker's event queue
/// via <see cref="MxAccessAlarmEventSink"/>.
/// </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.
/// </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.
/// </para>
/// </remarks>
public sealed class AlarmClientConsumer : IMxAccessAlarmConsumer
{
private const string DefaultProductName = "OtOpcUa.MxGateway";
private const string DefaultApplicationName = "OtOpcUa.MxGateway.Worker";
private const string DefaultVersion = "1.0";
private readonly AlarmClient client;
private readonly object subscribeLock = new object();
private bool disposed;
public AlarmClientConsumer()
: this(new AlarmClient())
{
}
/// <summary>Test seam — inject a pre-created <see cref="AlarmClient"/>.</summary>
internal AlarmClientConsumer(AlarmClient client)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <inheritdoc />
public event EventHandler<AlarmRecord>? AlarmRecordReceived;
/// <inheritdoc />
public void Subscribe(string subscription)
{
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
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.
int registerResult = client.RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName,
szVersion: DefaultVersion,
bRetainHiddenAlarms: false);
if (registerResult != 0)
{
throw new InvalidOperationException(
$"AlarmClient.RegisterConsumer returned non-zero status {registerResult}.");
}
int subscribeResult = client.Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asNone,
FilterSpecification: eAlarmFilterState.asNone);
if (subscribeResult != 0)
{
throw new InvalidOperationException(
$"AlarmClient.Subscribe('{subscription}') returned non-zero status {subscribeResult}.");
}
}
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
return client.AlarmAckByGUID(
alarmGuid,
ackComment ?? string.Empty,
ackOperatorName ?? string.Empty,
ackOperatorNode ?? string.Empty,
ackOperatorDomain ?? string.Empty,
ackOperatorFullName ?? string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<AlarmRecord> SnapshotActiveAlarms()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmClientConsumer));
// Walk the alarm-client's view of currently-active alarms via
// GetStatistics + GetAlarmExtendedRec. The exact iteration semantics
// (whether ChangePos points at the active set or at the recently-
// changed set) need dev-rig validation; this method is a stub-grade
// walker that reports the count it found.
int percent = 0, total = 0, active = 0, suppressed = 0;
int suppressedFilters = 0, newAlarms = 0, changes = 0;
int[] codes = Array.Empty<int>();
int[] positions = Array.Empty<int>();
int[] handles = Array.Empty<int>();
int statsResult = client.GetStatistics(
ref percent, ref total, ref active, ref suppressed,
ref suppressedFilters, ref newAlarms, ref changes,
ref codes, ref positions, ref handles);
if (statsResult != 0 || positions == null)
{
return Array.Empty<AlarmRecord>();
}
List<AlarmRecord> records = new List<AlarmRecord>(positions.Length);
foreach (int pos in positions)
{
AlarmRecord record = new AlarmRecord();
int recResult = client.GetAlarmExtendedRec(pos, ref record);
if (recResult == 0)
{
records.Add(record);
}
}
return records;
}
/// <summary>
/// Forward an alarm record to subscribers. Exposed internal so the
/// dev-rig hookup that wires the AVEVA alarm-changes callback can
/// route into the same event-fan-out path tests use.
/// </summary>
internal void RaiseAlarmRecordReceived(AlarmRecord record)
{
AlarmRecordReceived?.Invoke(this, record);
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
try { client.DeregisterConsumer(); } catch { }
try { client.Dispose(); } catch { }
}
}
@@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
/// <see cref="AlarmDispatcher"/> (with a wnwrap-backed
/// <see cref="WnWrapAlarmConsumer"/> by default) on the first
/// <see cref="Subscribe"/> call, then routes
/// <see cref="Acknowledge"/> / <see cref="QueryActive"/> /
/// <see cref="Unsubscribe"/> through the same instance for the
/// session's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// Construction is dependency-injectable: the consumer factory
/// (default <c>() =&gt; new WnWrapAlarmConsumer()</c>) lets tests
/// substitute a fake without touching AVEVA COM. The event queue
/// is supplied by the owning <see cref="MxAccessStaSession"/> so
/// the alarm-side proto events land on the same queue the worker
/// already drains for IPC dispatch.
/// </para>
/// <para>
/// Threading: invoked from <see cref="MxAccessCommandExecutor"/>
/// which runs on the STA. The wnwrap consumer's polling timer
/// fires on a thread-pool thread; the only cross-thread surface
/// is the <see cref="AlarmDispatcher"/>'s event handler, which
/// hand-offs into the thread-safe <see cref="MxAccessEventQueue"/>.
/// </para>
/// </remarks>
public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
private readonly MxAccessEventQueue eventQueue;
private readonly Func<IMxAccessAlarmConsumer> consumerFactory;
private readonly object syncRoot = new object();
private AlarmDispatcher? dispatcher;
private bool disposed;
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer())
{
}
/// <summary>Test seam — inject a custom consumer factory.</summary>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
}
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
}
/// <inheritdoc />
public void Subscribe(string subscription, string sessionId)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
lock (syncRoot)
{
if (dispatcher is not null)
{
throw new InvalidOperationException(
"AlarmCommandHandler already has an active subscription; " +
"call Unsubscribe before issuing another SubscribeAlarms command.");
}
IMxAccessAlarmConsumer consumer = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(
eventQueue, new MxAccessEventMapper());
dispatcher = new AlarmDispatcher(consumer, sink, sessionId ?? string.Empty);
try
{
dispatcher.Subscribe(subscription);
}
catch
{
try { dispatcher.Dispose(); } catch { /* swallow */ }
dispatcher = null;
throw;
}
}
}
/// <inheritdoc />
public void Unsubscribe()
{
AlarmDispatcher? toDispose;
lock (syncRoot)
{
toDispose = dispatcher;
dispatcher = null;
}
toDispose?.Dispose();
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
{
AlarmDispatcher? d = GetDispatcherOrThrow();
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms();
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List<ActiveAlarmSnapshot> filtered = new List<ActiveAlarmSnapshot>(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
{
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
{
filtered.Add(snap);
}
}
return filtered;
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
if (d is null)
{
throw new InvalidOperationException(
"AlarmCommandHandler has no active subscription; " +
"call SubscribeAlarms before issuing alarm-related commands.");
}
return d;
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
Unsubscribe();
}
}
/// <summary>
/// Per-session interface routing the worker's alarm IPC commands —
/// <c>SubscribeAlarmsCommand</c>, <c>AcknowledgeAlarmCommand</c>,
/// <c>QueryActiveAlarmsCommand</c>, <c>UnsubscribeAlarmsCommand</c> —
/// to the underlying <see cref="AlarmDispatcher"/>. Production binding
/// is <see cref="AlarmCommandHandler"/>; tests substitute a fake.
/// </summary>
public interface IAlarmCommandHandler : IDisposable
{
/// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary>
void Subscribe(string subscription, string sessionId);
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary>
/// Acknowledge a single alarm by (name, provider, group) — used when
/// the caller has the human-readable reference but not the GUID.
/// </summary>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary>
/// Snapshot the currently-active alarm set, optionally scoped to a
/// prefix matched against <c>AlarmFullReference</c>.
/// </summary>
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
}
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// In-process dispatcher that owns the lifetime of an
/// <see cref="IMxAccessAlarmConsumer"/> + <see cref="MxAccessAlarmEventSink"/>
/// pair, and wires the consumer's <c>AlarmTransitionEmitted</c> stream
/// onto the sink's <c>EnqueueTransition</c> path so transitions land on
/// the worker's <see cref="MxAccessEventQueue"/> as proto
/// <see cref="OnAlarmTransitionEvent"/> messages ready for IPC dispatch.
/// </summary>
/// <remarks>
/// <para>
/// This is the in-process slice of A.3 — it proves the
/// consumer→sink→queue pipeline end-to-end without touching the
/// worker's IPC command framing. The companion follow-up PR adds
/// <c>SubscribeAlarmsCommand</c> / <c>AcknowledgeAlarmCommand</c> /
/// <c>QueryActiveAlarmsCommand</c> proto entries plus the gateway-
/// side <c>WorkerAlarmRpcDispatcher</c> that issues them.
/// </para>
/// <para>
/// Threading: <see cref="WnWrapAlarmConsumer"/> polls on a
/// <see cref="System.Threading.Timer"/> thread today; production
/// hosting should marshal the consumer onto the worker's STA via
/// <c>StaRuntime.InvokeAsync</c>. The dispatcher itself is purely
/// a pass-through, so it inherits whatever thread the consumer's
/// event handler fires on. Fan-out into <c>EnqueueTransition</c>
/// uses <see cref="MxAccessEventQueue.Enqueue"/> which is
/// thread-safe.
/// </para>
/// </remarks>
public sealed class AlarmDispatcher : IDisposable
{
private readonly IMxAccessAlarmConsumer consumer;
private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler;
private bool disposed;
public AlarmDispatcher(
IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink,
string sessionId)
{
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty;
// Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference
// for the alarm path, but the existing IMxAccessEventSink contract
// requires a non-null first arg.
this.sink.Attach(this.consumer, this.sessionId);
this.handler = OnTransition;
consumer.AlarmTransitionEmitted += handler;
}
/// <summary>
/// Begin polling the configured AVEVA alarm provider for
/// transitions. The supplied subscription expression follows the
/// canonical <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> format.
/// </summary>
public void Subscribe(string subscription)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
consumer.Subscribe(subscription);
}
/// <summary>
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success).
/// </summary>
public int Acknowledge(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByGuid(
alarmGuid,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
/// <summary>
/// Acknowledge an alarm by its (name, provider, group) tuple.
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
/// </summary>
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByName(
alarmName,
providerName,
groupName,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
/// <summary>
/// Snapshot the currently-active alarm set as
/// <see cref="ActiveAlarmSnapshot"/> protos for the
/// <c>QueryActiveAlarms</c> RPC's ConditionRefresh stream.
/// </summary>
public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
IReadOnlyList<MxAlarmSnapshotRecord> records = consumer.SnapshotActiveAlarms();
if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>();
List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count);
foreach (MxAlarmSnapshotRecord record in records)
{
snapshots.Add(MapToSnapshot(record));
}
return snapshots;
}
private void OnTransition(object? sender, MxAlarmTransitionEvent transition)
{
if (disposed) return;
if (transition is null) return;
MxAlarmSnapshotRecord record = transition.Record;
AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition(
transition.PreviousState, record.State);
if (kind == AlarmTransitionKind.Unspecified) return;
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName);
sink.EnqueueTransition(
alarmFullReference: fullReference,
sourceObjectReference: record.TagName,
alarmTypeName: record.Type,
transitionKind: kind,
severity: record.Priority,
originalRaiseTimestampUtc: null,
transitionTimestampUtc: record.TransitionTimestampUtc,
operatorUser: record.OperatorName,
operatorComment: record.AlarmComment,
category: record.Group,
description: string.Empty);
}
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
{
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
{
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName),
SourceObjectReference = record.TagName,
AlarmTypeName = record.Type,
CurrentState = MapConditionState(record.State),
Severity = record.Priority,
OperatorUser = record.OperatorName,
OperatorComment = record.AlarmComment,
Category = record.Group,
Description = string.Empty,
};
if (record.TransitionTimestampUtc != DateTime.MinValue)
{
snapshot.LastTransitionTimestamp =
Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(record.TransitionTimestampUtc, DateTimeKind.Utc));
}
return snapshot;
}
private static AlarmConditionState MapConditionState(MxAlarmStateKind state)
{
// The proto's AlarmConditionState only distinguishes Active /
// ActiveAcked / Inactive — both Rtn states collapse to Inactive
// (the ack-vs-unack distinction on a cleared alarm is not exposed
// through OPC UA's Part 9 condition state model anyway).
return state switch
{
MxAlarmStateKind.UnackAlm => AlarmConditionState.Active,
MxAlarmStateKind.AckAlm => AlarmConditionState.ActiveAcked,
MxAlarmStateKind.UnackRtn => AlarmConditionState.Inactive,
MxAlarmStateKind.AckRtn => AlarmConditionState.Inactive,
_ => AlarmConditionState.Unspecified,
};
}
public string SessionId => sessionId;
public void Dispose()
{
if (disposed) return;
disposed = true;
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
try { sink.Detach(); } catch { /* swallow */ }
try { consumer.Dispose(); } catch { /* swallow */ }
}
}
@@ -1,46 +1,77 @@
using System; using System;
using AlarmMgrDataProviderCOM;
using MxGateway.Contracts.Proto; using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess; namespace MxGateway.Worker.MxAccess;
/// <summary> /// <summary>
/// PR A.5 — translation helpers between AVEVA's /// Translation helpers between the wnwrapConsumer XML payload and the
/// <see cref="eAlmTransitions"/> enum and the proto's /// proto-friendly <see cref="AlarmTransitionKind"/> wire format, plus
/// <see cref="AlarmTransitionKind"/>, plus alarm-reference composition. /// alarm-reference composition.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The full <see cref="AlarmRecord"/> → proto-fields decoder lives /// These mappings stay pure and library-agnostic so they're unit
/// in <see cref="AlarmClientConsumer"/>. The two pieces that don't /// testable without an AVEVA install. The COM-side I/O lives on
/// need hardware validation (transition-kind enum mapping + /// <see cref="WnWrapAlarmConsumer"/>.
/// provider/group/name → reference string format) live here so the
/// consumer's hot-path stays focused on COM-side field access.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class AlarmRecordTransitionMapper public static class AlarmRecordTransitionMapper
{ {
/// <summary> /// <summary>
/// Maps the AVEVA <see cref="eAlmTransitions"/> enum onto the proto's /// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
/// <see cref="AlarmTransitionKind"/>. Transitions outside the four /// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
/// primary kinds (raise/ack/clear/retrigger) collapse to /// <see cref="MxAlarmStateKind"/>. Unknown values map to
/// <see cref="AlarmTransitionKind.Unspecified"/> so the EventPump's /// <see cref="MxAlarmStateKind.Unspecified"/>.
/// decoding-failure counter records them.
/// </summary> /// </summary>
public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native) public static MxAlarmStateKind ParseStateKind(string? stateXml)
{ {
// ALM = active-raise, RTN = return-to-normal/clear, ACK = acknowledge. if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
// SUB / ENB / DIS / SUP / REL / REMOVE — substitute / enable / disable / return stateXml!.Trim().ToUpperInvariant() switch
// suppress / release / remove. None of those map to OPC UA Part 9
// transitions today; future work could add a Substituted / Suppressed
// proto kind if a customer needs it.
switch (native)
{ {
case eAlmTransitions.almRec_trans_ALM: return AlarmTransitionKind.Raise; "UNACK_ALM" => MxAlarmStateKind.UnackAlm,
case eAlmTransitions.almRec_trans_ACK: return AlarmTransitionKind.Acknowledge; "ACK_ALM" => MxAlarmStateKind.AckAlm,
case eAlmTransitions.almRec_trans_RTN: return AlarmTransitionKind.Clear; "UNACK_RTN" => MxAlarmStateKind.UnackRtn,
default: return AlarmTransitionKind.Unspecified; "ACK_RTN" => MxAlarmStateKind.AckRtn,
_ => MxAlarmStateKind.Unspecified,
};
} }
/// <summary>
/// Decide which proto transition kind a state change represents.
/// The decision table:
/// <list type="bullet">
/// <item><description><c>previous=Unspecified</c> + <c>current=*Alm</c> → Raise (new alarm).</description></item>
/// <item><description><c>previous=Unspecified</c> + <c>current=*Rtn</c> → Clear (alarm appeared in cleared state — rare; missed the raise).</description></item>
/// <item><description><c>previous=Unack*</c> + <c>current=Ack*</c> → Acknowledge.</description></item>
/// <item><description><c>previous=*Alm</c> + <c>current=*Rtn</c> → Clear.</description></item>
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
/// </list>
/// </summary>
public static AlarmTransitionKind MapTransition(
MxAlarmStateKind previous,
MxAlarmStateKind current)
{
if (current == MxAlarmStateKind.Unspecified) return AlarmTransitionKind.Unspecified;
bool currentIsAlm = current is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
bool currentIsRtn = current is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
bool currentIsAcked = current is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
if (previous == MxAlarmStateKind.Unspecified)
{
return currentIsAlm ? AlarmTransitionKind.Raise : AlarmTransitionKind.Clear;
}
bool previousIsAlm = previous is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
bool previousIsRtn = previous is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
bool previousIsAcked = previous is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
if (previousIsAlm && currentIsRtn) return AlarmTransitionKind.Clear;
if (previousIsRtn && currentIsAlm) return AlarmTransitionKind.Raise;
if (!previousIsAcked && currentIsAcked) return AlarmTransitionKind.Acknowledge;
return AlarmTransitionKind.Unspecified;
} }
/// <summary> /// <summary>
@@ -63,4 +94,90 @@ public static class AlarmRecordTransitionMapper
? $"{provider}!{name}" ? $"{provider}!{name}"
: $"{provider}!{group}.{name}"; : $"{provider}!{group}.{name}";
} }
/// <summary>
/// Reassemble a UTC <see cref="DateTime"/> from the wnwrap XML's
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
/// fields. Returns <see cref="DateTime.MinValue"/> when DATE / TIME
/// can't be parsed (best-effort — failure is non-fatal; the proto
/// will carry the epoch and the EventQueue's fault counter records
/// the parse miss).
/// </summary>
/// <param name="xmlDate">e.g. <c>"2026/5/1"</c> (no zero-padding).</param>
/// <param name="xmlTime">e.g. <c>"13:26:14.709"</c>.</param>
/// <param name="gmtOffsetMinutes">Offset of the producer's local time vs UTC, in minutes.</param>
/// <param name="dstAdjustMinutes">DST adjustment already applied to local time, in minutes.</param>
public static DateTime ParseTransitionTimestampUtc(
string? xmlDate,
string? xmlTime,
int gmtOffsetMinutes,
int dstAdjustMinutes)
{
if (string.IsNullOrWhiteSpace(xmlDate) || string.IsNullOrWhiteSpace(xmlTime))
{
return DateTime.MinValue;
}
// Parse DATE: yyyy/M/d (no zero padding observed). Use ParseExact with
// multiple format candidates — AVEVA's locale may format differently
// on non-en-US hosts.
string[] dateFormats =
{
"yyyy/M/d", "yyyy/MM/dd", "M/d/yyyy", "MM/dd/yyyy",
"d/M/yyyy", "dd/MM/yyyy",
};
string dateTrim = xmlDate!.Trim();
if (!DateTime.TryParseExact(
dateTrim,
dateFormats,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out DateTime date))
{
if (!DateTime.TryParse(
dateTrim,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out date))
{
return DateTime.MinValue;
}
}
// Parse TIME: H:m:s.fff (variable precision).
string[] timeFormats =
{
"H:m:s.fff", "H:m:s.ff", "H:m:s.f", "H:m:s",
"HH:mm:ss.fff", "HH:mm:ss.ff", "HH:mm:ss.f", "HH:mm:ss",
};
string timeTrim = xmlTime!.Trim();
if (!DateTime.TryParseExact(
timeTrim,
timeFormats,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out DateTime time))
{
if (!DateTime.TryParse(
timeTrim,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out time))
{
return DateTime.MinValue;
}
}
DateTime localProducerTime = new DateTime(
date.Year, date.Month, date.Day,
time.Hour, time.Minute, time.Second, time.Millisecond,
DateTimeKind.Unspecified);
// GMTOFFSET = minutes east of UTC (or behind, depending on convention).
// The wnwrap convention observed: GMTOFFSET=240, DSTADJUST=0 for
// EDT (UTC-4) — so the field is "minutes from local to UTC". To get
// UTC, ADD the offset.
DateTime utc = localProducerTime.AddMinutes(gmtOffsetMinutes - dstAdjustMinutes);
return DateTime.SpecifyKind(utc, DateTimeKind.Utc);
}
} }
@@ -1,40 +1,57 @@
using System; using System;
using AlarmMgrDataProviderCOM; using System.Collections.Generic;
namespace MxGateway.Worker.MxAccess; namespace MxGateway.Worker.MxAccess;
/// <summary> /// <summary>
/// PR A.5 — abstraction over <c>aaAlarmManagedClient.AlarmClient</c>'s /// Abstraction over an AVEVA alarm-consumer COM library. The production
/// subscribe / event-receive surface. The production implementation /// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
/// (<see cref="AlarmClientConsumer"/>) wraps the AVEVA managed client; /// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
/// tests substitute a fake to exercise the wiring against canned /// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
/// <see cref="AlarmRecord"/> events without a live Galaxy. /// tests substitute a fake to drive transition events without a live
/// Galaxy.
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// The receive surface is poll-based: the production consumer
/// periodically calls <c>GetXmlCurrentAlarms2</c>, parses the
/// returned XML payload, diffs against the previous snapshot keyed
/// by alarm GUID, and raises <see cref="AlarmTransitionEmitted"/>
/// once per state change. This bypasses the FILETIME marshaling
/// crash in <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>
/// (see <c>docs/AlarmClientDiscovery.md</c>) — XML strings carry
/// timestamps as ASCII fields, no DateTime auto-conversion happens
/// on the .NET interop boundary.
/// </para>
/// </remarks>
public interface IMxAccessAlarmConsumer : IDisposable public interface IMxAccessAlarmConsumer : IDisposable
{ {
/// <summary> /// <summary>
/// Fires once per alarm record the AVEVA alarm provider emits. The /// Fires once per detected alarm-state transition (raise, acknowledge,
/// subscriber is expected to forward each record to a transition mapper /// clear, or new-alarm-already-acked-on-arrival). Subscribers are
/// and then onto the worker's event queue. Fired on the alarm-client's /// expected to translate the record into the proto family
/// internal callback thread; subscribers that need STA affinity must /// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
/// marshal back themselves. /// polling thread (the worker's STA in production); subscribers that
/// need a different thread must marshal back themselves.
/// </summary> /// </summary>
event EventHandler<AlarmRecord>? AlarmRecordReceived; event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary> /// <summary>
/// Initializes the AVEVA alarm-client connection and subscribes to the /// Initializes the AVEVA alarm-client connection, registers as a
/// supplied alarm-provider expression. Subscription string follows /// consumer, and subscribes to the supplied alarm-provider expression.
/// AVEVA's syntax (e.g. <c>"\Galaxy!OperationsRoom.AlarmGroup"</c> or /// Subscription string follows AVEVA's canonical format:
/// <c>"\\GR1\Galaxy!"</c> for a whole Galaxy). /// <c>\\&lt;node&gt;\Galaxy!&lt;area&gt;</c>. The literal "Galaxy" is
/// the provider name (regardless of the configured Galaxy database
/// name). Calling Subscribe also begins polling on the consumer's
/// internal timer.
/// </summary> /// </summary>
void Subscribe(string subscription); void Subscribe(string subscription);
/// <summary> /// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity. /// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches the AVEVA alarm provider's native ack API /// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
/// (<c>AlarmAckByGUID</c>); operator user / node / domain / full-name /// user / node / domain / full-name and the comment land atomically
/// and the comment land atomically with the ack transition in the /// with the ack transition in the alarm-history log.
/// alarm-history log.
/// </summary> /// </summary>
int AcknowledgeByGuid( int AcknowledgeByGuid(
Guid alarmGuid, Guid alarmGuid,
@@ -45,10 +62,27 @@ public interface IMxAccessAlarmConsumer : IDisposable
string ackOperatorFullName); string ackOperatorFullName);
/// <summary> /// <summary>
/// Walks the currently-active alarm set and yields each as an /// Acknowledge a single alarm by its (name, provider, group) tuple.
/// <see cref="AlarmRecord"/>. Used by the gateway's QueryActiveAlarms /// Reaches AVEVA's <c>AlarmAckByName</c> on
/// (PR A.7) ConditionRefresh path — operator clients call this after /// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
/// reconnect to seed local Part 9 state. /// <see cref="AcknowledgeByGuid"/>, used when the caller has the
/// human-readable reference but not the canonical GUID.
/// </summary> /// </summary>
System.Collections.Generic.IReadOnlyList<AlarmRecord> SnapshotActiveAlarms(); int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary>
/// Returns the consumer's most recently parsed snapshot of currently
/// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7)
/// ConditionRefresh path — operator clients call this after reconnect
/// to seed local Part 9 state.
/// </summary>
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
} }
@@ -4,49 +4,21 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess; namespace MxGateway.Worker.MxAccess;
/// <summary> /// <summary>
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the /// Sink for native MxAccess alarm transitions. Bridges
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's /// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages /// producing <see cref="OnAlarmTransitionEvent"/> messages via
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>. /// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <strong>Architecture (pinned 2026-04-30):</strong> the worker hosts /// The dispatcher subscribes the consumer's
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing /// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET Framework /// to <see cref="EnqueueTransition"/> at session attach time. The
/// 4.8 — the worker's existing runtime — and both use the same Windows /// <see cref="Attach"/> override here is a stub kept for the data-
/// STA + WM_APP message pump. The MxAccess COM Toolkit at /// session shape; the actual wire-up between consumer and sink
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c> /// lives in the A.3 dispatcher (one step up the stack). Captured
/// exposes no alarm events; the alarm provider lives in a separate /// payload schema and consumer threading discipline are described in
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to. /// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
/// </para>
/// <para>
/// <strong>Discovered API surface</strong> (see
/// <c>AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface</c> in
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe):
/// </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>Wiring plan (subsequent PRs):</strong>
/// </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>
/// </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.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
@@ -13,13 +13,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
{ {
private readonly MxAccessSession session; private readonly MxAccessSession session;
private readonly VariantConverter variantConverter; private readonly VariantConverter variantConverter;
private readonly IAlarmCommandHandler? alarmCommandHandler;
/// <summary> /// <summary>
/// Initializes a command executor with an MXAccess session. /// Initializes a command executor with an MXAccess session.
/// </summary> /// </summary>
/// <param name="session">MXAccess session on the STA thread.</param> /// <param name="session">MXAccess session on the STA thread.</param>
public MxAccessCommandExecutor(MxAccessSession session) public MxAccessCommandExecutor(MxAccessSession session)
: this(session, new VariantConverter()) : this(session, new VariantConverter(), alarmCommandHandler: null)
{ {
} }
@@ -31,9 +32,24 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
public MxAccessCommandExecutor( public MxAccessCommandExecutor(
MxAccessSession session, MxAccessSession session,
VariantConverter variantConverter) VariantConverter variantConverter)
: this(session, variantConverter, alarmCommandHandler: null)
{
}
/// <summary>
/// Initializes a command executor with an MXAccess session, variant
/// converter, and an alarm command handler. The alarm handler is
/// optional — when null, alarm-side commands return an
/// "alarm consumer not configured" diagnostic.
/// </summary>
public MxAccessCommandExecutor(
MxAccessSession session,
VariantConverter variantConverter,
IAlarmCommandHandler? alarmCommandHandler)
{ {
this.session = session ?? throw new ArgumentNullException(nameof(session)); this.session = session ?? throw new ArgumentNullException(nameof(session));
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.alarmCommandHandler = alarmCommandHandler;
} }
/// <summary> /// <summary>
@@ -64,6 +80,11 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command), MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command),
MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command), MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command),
MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command), MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command),
MxCommandKind.SubscribeAlarms => ExecuteSubscribeAlarms(command),
MxCommandKind.UnsubscribeAlarms => ExecuteUnsubscribeAlarms(command),
MxCommandKind.AcknowledgeAlarm => ExecuteAcknowledgeAlarm(command),
MxCommandKind.AcknowledgeAlarmByName => ExecuteAcknowledgeAlarmByName(command),
MxCommandKind.QueryActiveAlarms => ExecuteQueryActiveAlarms(command),
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."), _ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
}; };
} }
@@ -280,6 +301,201 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles)); session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles));
} }
private MxCommandReply ExecuteSubscribeAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeAlarms)
{
return CreateInvalidRequestReply(command, "SubscribeAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"SubscribeAlarms requires an alarm command handler; the worker was constructed without one.");
}
string subscription = command.Command.SubscribeAlarms.SubscriptionExpression ?? string.Empty;
if (string.IsNullOrWhiteSpace(subscription))
{
return CreateInvalidRequestReply(command, "SubscribeAlarms.subscription_expression is required.");
}
try
{
alarmCommandHandler.Subscribe(subscription, command.SessionId);
return CreateOkReply(command);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteUnsubscribeAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnsubscribeAlarms)
{
return CreateInvalidRequestReply(command, "UnsubscribeAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
// No handler configured — Unsubscribe is a no-op in that case;
// it can't be in a subscribed state to begin with.
return CreateOkReply(command);
}
try
{
alarmCommandHandler.Unsubscribe();
return CreateOkReply(command);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteAcknowledgeAlarm(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmCommand)
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarm command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"AcknowledgeAlarm requires an alarm command handler; the worker was constructed without one.");
}
AcknowledgeAlarmCommand payload = command.Command.AcknowledgeAlarmCommand;
if (!Guid.TryParse(payload.AlarmGuid, out Guid alarmGuid))
{
return CreateInvalidRequestReply(
command,
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
}
try
{
int rc = alarmCommandHandler.Acknowledge(
alarmGuid,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
}
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteAcknowledgeAlarmByName(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand)
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"AcknowledgeAlarmByName requires an alarm command handler; the worker was constructed without one.");
}
AcknowledgeAlarmByNameCommand payload = command.Command.AcknowledgeAlarmByNameCommand;
if (string.IsNullOrWhiteSpace(payload.AlarmName))
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
}
try
{
int rc = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName,
payload.ProviderName,
payload.GroupName,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
}
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
{
return CreateInvalidRequestReply(command, "QueryActiveAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"QueryActiveAlarms requires an alarm command handler; the worker was constructed without one.");
}
try
{
IReadOnlyList<ActiveAlarmSnapshot> snapshots = alarmCommandHandler.QueryActive(
command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
payload.Snapshots.AddRange(snapshots);
MxCommandReply reply = CreateOkReply(command);
reply.QueryActiveAlarms = payload;
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = exception.Message,
},
DiagnosticMessage = $"{exception.GetType().FullName}: {exception.Message}",
};
}
private static MxCommandReply CreateOkReply(StaCommand command) private static MxCommandReply CreateOkReply(StaCommand command)
{ {
return new MxCommandReply return new MxCommandReply
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MxGateway.Contracts.Proto; using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
using MxGateway.Worker.Sta; using MxGateway.Worker.Sta;
namespace MxGateway.Worker.MxAccess; namespace MxGateway.Worker.MxAccess;
@@ -14,8 +15,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
private readonly IMxAccessEventSink eventSink; private readonly IMxAccessEventSink eventSink;
private readonly MxAccessEventQueue eventQueue; private readonly MxAccessEventQueue eventQueue;
private readonly StaRuntime staRuntime; private readonly StaRuntime staRuntime;
private readonly Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory;
private StaCommandDispatcher? commandDispatcher; private StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session; private MxAccessSession? session;
private IAlarmCommandHandler? alarmCommandHandler;
private bool disposed; private bool disposed;
/// <summary> /// <summary>
@@ -69,11 +72,29 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
IMxAccessComObjectFactory factory, IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink, IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue) MxAccessEventQueue eventQueue)
: this(staRuntime, factory, eventSink, eventQueue, alarmCommandHandlerFactory: null)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all
/// dependencies including an alarm-command handler factory. The factory is
/// invoked on the STA thread during <see cref="StartAsync(string, int, CancellationToken)"/>;
/// pass <c>null</c> to opt out of alarm-side commands (the worker rejects
/// them with an "alarm consumer not configured" diagnostic).
/// </summary>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue,
Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory)
{ {
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime)); this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
} }
/// <summary> /// <summary>
@@ -117,9 +138,16 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
} }
session = MxAccessSession.Create(factory, eventSink, sessionId); session = MxAccessSession.Create(factory, eventSink, sessionId);
if (alarmCommandHandlerFactory is not null)
{
alarmCommandHandler = alarmCommandHandlerFactory(eventQueue);
}
commandDispatcher = new StaCommandDispatcher( commandDispatcher = new StaCommandDispatcher(
staRuntime, staRuntime,
new MxAccessCommandExecutor(session)); new MxAccessCommandExecutor(
session,
new VariantConverter(),
alarmCommandHandler));
return session.CreateWorkerReady(workerProcessId); return session.CreateWorkerReady(workerProcessId);
}, },
@@ -279,6 +307,27 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
commandDispatcher?.RequestShutdown(); commandDispatcher?.RequestShutdown();
// Stop the alarm consumer's polling timer and tear down the
// dispatcher BEFORE the data-side cleanup begins. The alarm
// consumer holds a wnwrap COM RCW that needs the STA pump to
// unwind cleanly; doing it here gives it the opportunity while
// the STA is still alive.
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
await staRuntime.InvokeAsync(
() => alarmHandlerToDispose.Dispose(),
cancellationToken).ConfigureAwait(false);
}
catch
{
// Swallow — alarm cleanup must not block data shutdown.
}
}
Stopwatch stopwatch = Stopwatch.StartNew(); Stopwatch stopwatch = Stopwatch.StartNew();
MxAccessShutdownResult result; MxAccessShutdownResult result;
if (session is null) if (session is null)
@@ -333,6 +382,19 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
RequestShutdown(); RequestShutdown();
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
staRuntime.InvokeAsync(() => alarmHandlerToDispose.Dispose())
.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException) { }
catch (ObjectDisposedException) { }
}
if (session is not null) if (session is not null)
{ {
try try
@@ -0,0 +1,59 @@
using System;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
/// Decoupling the consumer from any specific COM library keeps the
/// proto-build path testable without an AVEVA install.
/// </summary>
public enum MxAlarmStateKind
{
Unspecified = 0,
UnackAlm = 1,
AckAlm = 2,
UnackRtn = 3,
AckRtn = 4,
}
/// <summary>
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
/// Field names match the captured XML schema (see
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
/// </summary>
public sealed class MxAlarmSnapshotRecord
{
public Guid AlarmGuid { get; set; }
public DateTime TransitionTimestampUtc { get; set; }
public string ProviderNode { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Limit { get; set; } = string.Empty;
public int Priority { get; set; }
public MxAlarmStateKind State { get; set; }
public string OperatorNode { get; set; } = string.Empty;
public string OperatorName { get; set; } = string.Empty;
public string AlarmComment { get; set; } = string.Empty;
}
/// <summary>
/// One transition emitted by the consumer's snapshot diff. Pairs the
/// latest record with its previous state so the proto layer can decide
/// whether the transition is a Raise / Acknowledge / Clear.
/// </summary>
public sealed class MxAlarmTransitionEvent : EventArgs
{
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
/// <summary>
/// The state on the consumer's previous polled snapshot, or
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
/// first time the GUID has been observed.
/// </summary>
public MxAlarmStateKind PreviousState { get; set; }
}
@@ -0,0 +1,548 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Threading;
using System.Xml;
using WNWRAPCONSUMERLib;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
/// standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
/// (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
/// </summary>
/// <remarks>
/// <para>
/// Replaces the earlier <c>AlarmClientConsumer</c> built on
/// <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
/// <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
/// The wnwrap surface returns the alarm record as a BSTR XML string
/// via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
/// fields and never touch the .NET DateTime marshaler. See
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
/// the discovery and the captured payload schema.
/// </para>
/// <para>
/// <strong>Threading.</strong> The wnwrap CLSID is registered with
/// <c>ThreadingModel=Apartment</c>. The consumer must be created
/// and operated from an STA thread; the worker's
/// <see cref="MxAccessStaSession"/> already runs an STA pump that
/// is the natural host. Polling cadence is governed by
/// <see cref="PollIntervalMilliseconds"/> on a dedicated timer the
/// consumer owns; in production the worker's STA dispatcher should
/// marshal each callback onto the STA thread before invoking
/// <c>GetXmlCurrentAlarms2</c>. For now (test-grade), this consumer
/// calls the COM API on whichever thread the timer fires it on —
/// the worker bootstrap will gain a thin "run-on-STA" wrapper as
/// part of A.3 dispatcher wiring.
/// </para>
/// </remarks>
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
{
private const string DefaultProductName = "OtOpcUa.MxGateway";
private const string DefaultApplicationName = "OtOpcUa.MxGateway.Worker";
private const string DefaultVersion = "1.0";
private const int DefaultPollIntervalMilliseconds = 500;
private const int DefaultMaxAlarmsPerFetch = 1024;
private readonly object syncRoot = new object();
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
private readonly int pollIntervalMs;
private readonly int maxAlarmsPerFetch;
private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private string subscriptionExpression = string.Empty;
private Timer? pollTimer;
private bool subscribed;
private bool disposed;
public WnWrapAlarmConsumer()
: this(new wwAlarmConsumerClass(), DefaultPollIntervalMilliseconds, DefaultMaxAlarmsPerFetch)
{
}
/// <summary>
/// Test seam / explicit construction — inject a pre-created COM
/// client and tune the poll cadence. <c>pollIntervalMilliseconds == 0</c>
/// disables the internal <see cref="Timer"/> entirely; the caller
/// must drive <see cref="PollOnce"/> manually (used by hosts that
/// marshal polls onto a foreign STA, and by live smoke tests that
/// pump from the STA they own).
/// </summary>
public WnWrapAlarmConsumer(
wwAlarmConsumerClass client,
int pollIntervalMilliseconds,
int maxAlarmsPerFetch)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.pollIntervalMs = pollIntervalMilliseconds < 0
? DefaultPollIntervalMilliseconds
: pollIntervalMilliseconds;
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
? maxAlarmsPerFetch
: DefaultMaxAlarmsPerFetch;
}
/// <inheritdoc />
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
public int PollIntervalMilliseconds => pollIntervalMs;
/// <inheritdoc />
public void Subscribe(string subscription)
{
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
lock (syncRoot)
{
if (subscribed)
{
throw new InvalidOperationException(
"WnWrapAlarmConsumer.Subscribe was called more than once; " +
"wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
}
wwAlarmConsumerClass com = client
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
// lifecycle. Empirically (live dev-rig 2026-05-01) this is the
// only path that lets AlarmAckByName succeed afterwards. The
// v2 Initialize/Register/Subscribe methods on the class
// succeed (return 0) but acks against that consumer state
// return -55. The v1 prefix path is what WIN-911-style code
// uses against the same wnwrap library.
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
if (init != 0)
{
throw new InvalidOperationException(
$"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
}
// hWnd=0: wnwrap supports a pull-based model — no message pump
// is required. We poll GetXmlCurrentAlarms2 on a timer below.
int reg = com.IwwAlarmConsumer_RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName,
szVersion: DefaultVersion);
if (reg != 0)
{
throw new InvalidOperationException(
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
}
int sub = com.IwwAlarmConsumer_Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
if (sub != 0)
{
throw new InvalidOperationException(
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
}
// Empirically required: even though the round-trip echo of
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
// calling it is necessary for subsequent GetXmlCurrentAlarms2
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
// also breaks AlarmAckByName on the same consumer (rejects with
// -55), so a separate ack-only consumer is provisioned below
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
string xmlQuery = ComposeXmlAlarmQuery(subscription);
com.SetXmlAlarmQuery(xmlQuery);
// Provision a parallel COM consumer for ack calls. It runs the
// v1 lifecycle (Initialize/Register/Subscribe) only; without
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
// — we never poll this consumer.
ackClient = new wwAlarmConsumerClass();
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
hWnd: 0,
szProductName: DefaultProductName,
szApplicationName: DefaultApplicationName + ".ack",
szVersion: DefaultVersion);
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
szSubscription: subscription,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
{
throw new InvalidOperationException(
$"Ack consumer setup returned non-zero status: " +
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
}
subscriptionExpression = subscription;
subscribed = true;
if (pollIntervalMs > 0)
{
pollTimer = new Timer(OnPoll, state: null, dueTime: 0, period: pollIntervalMs);
}
}
}
/// <inheritdoc />
public int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
wwAlarmConsumerClass com = client
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
// VBGUID is wnwrap's GUID interop struct (same memory layout as
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
// unmanaged-blittable round-trip.
VBGUID vb = ToVbGuid(alarmGuid);
return com.AlarmAckByGUID(
AlmGUID: vb,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty,
szDomainName: ackOperatorDomain ?? string.Empty,
szOprFullName: ackOperatorFullName ?? string.Empty);
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
// empirical justification.
wwAlarmConsumerClass com = ackClient
?? throw new InvalidOperationException(
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
// Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2
// 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like
// a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
// and reaches the alarm-history path correctly. Operator-domain
// and operator-full-name fields are accepted by the proto contract
// for forward-compat but are not propagated to AVEVA today —
// wrapped in the 6-arg call so domain/full-name go to the
// alarm-history operator-name field via the szOprName parameter.
// Suppress unused-warning explicitly:
_ = ackOperatorDomain;
_ = ackOperatorFullName;
return com.AlarmAckByName(
szAlarmName: alarmName ?? string.Empty,
szProviderName: providerName ?? string.Empty,
szGroupName: groupName ?? string.Empty,
szComment: ackComment ?? string.Empty,
szOprName: ackOperatorName ?? string.Empty,
szNode: ackOperatorNode ?? string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
{
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
lock (syncRoot)
{
List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
{
if (record.State == MxAlarmStateKind.UnackAlm
|| record.State == MxAlarmStateKind.AckAlm)
{
active.Add(record);
}
}
return active;
}
}
private void OnPoll(object? _)
{
if (disposed) return;
try
{
PollOnce();
}
catch (Exception ex)
{
// Swallow — the poll loop must not propagate exceptions out of
// the timer callback, or the worker process tears down. The
// EventQueue fault counter (wired in by the future A.3 dispatcher)
// is the right place to surface poll failures; for now the
// exception is intentionally silent so the timer keeps firing.
_ = ex;
}
}
/// <summary>
/// Synchronously poll the wnwrap consumer once and dispatch any
/// transitions. Public so STA-bound hosts can drive polling from
/// the thread that owns the COM object instead of relying on the
/// internal <see cref="Timer"/> (which fires on a thread-pool
/// thread and blocks indefinitely on cross-apartment marshaling
/// when the host STA isn't pumping messages).
/// </summary>
public void PollOnce()
{
wwAlarmConsumerClass? com;
lock (syncRoot)
{
if (disposed || !subscribed) return;
com = client;
}
if (com is null) return;
object xmlObj = string.Empty;
com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
string xml = xmlObj?.ToString() ?? string.Empty;
if (xml.Length == 0) return;
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
lock (syncRoot)
{
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
if (latestSnapshot.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
{
previousState = prev.State;
if (previousState == kv.Value.State) continue; // no transition
}
transitions.Add(new MxAlarmTransitionEvent
{
Record = kv.Value,
PreviousState = previousState,
});
}
latestSnapshot.Clear();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
latestSnapshot[kv.Key] = kv.Value;
}
}
if (transitions.Count == 0) return;
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
if (handler is null) return;
foreach (MxAlarmTransitionEvent transition in transitions)
{
handler.Invoke(this, transition);
}
}
/// <summary>
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
/// silently dropped (no fault is recorded — the next poll will
/// resync).
/// </summary>
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
{
Dictionary<Guid, MxAlarmSnapshotRecord> records =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
if (string.IsNullOrWhiteSpace(xml)) return records;
XmlDocument doc = new XmlDocument();
doc.LoadXml(xml);
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
if (alarmNodes is null) return records;
foreach (XmlNode alarmNode in alarmNodes)
{
string guidHex = TextOf(alarmNode, "GUID");
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
string xmlDate = TextOf(alarmNode, "DATE");
string xmlTime = TextOf(alarmNode, "TIME");
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
xmlDate, xmlTime, gmtOffset, dstAdjust);
records[guid] = new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
TransitionTimestampUtc = tsUtc,
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
Group = TextOf(alarmNode, "GROUP"),
TagName = TextOf(alarmNode, "TAGNAME"),
Type = TextOf(alarmNode, "TYPE"),
Value = TextOf(alarmNode, "VALUE"),
Limit = TextOf(alarmNode, "LIMIT"),
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
};
}
return records;
}
private static string TextOf(XmlNode parent, string childName)
{
XmlNode? node = parent.SelectSingleNode(childName);
return node?.InnerText ?? string.Empty;
}
private static int ParseInt(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
? n : 0;
}
/// <summary>
/// wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
/// dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
/// to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
/// </summary>
public static bool TryParseHexGuid(string? hex, out Guid guid)
{
guid = Guid.Empty;
if (string.IsNullOrWhiteSpace(hex)) return false;
string trimmed = hex!.Trim();
if (Guid.TryParse(trimmed, out guid)) return true;
if (trimmed.Length != 32) return false;
string canonical =
trimmed.Substring(0, 8) + "-" +
trimmed.Substring(8, 4) + "-" +
trimmed.Substring(12, 4) + "-" +
trimmed.Substring(16, 4) + "-" +
trimmed.Substring(20, 12);
return Guid.TryParse(canonical, out guid);
}
/// <summary>
/// Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
/// canonical subscription expression
/// (<c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c>). The wnwrap
/// consumer mangles the round-trip but evidently still needs the
/// call — without it <c>GetXmlCurrentAlarms2</c> fails with
/// E_FAIL. Best-effort parse: if the subscription doesn't decompose
/// cleanly, fall back to a permissive ALL-priority/ALL-state form
/// so the worker doesn't fail to start.
/// </summary>
internal static string ComposeXmlAlarmQuery(string subscription)
{
string node = Environment.MachineName;
string provider = "Galaxy";
string group = string.Empty;
if (!string.IsNullOrEmpty(subscription))
{
// Strip leading backslashes from "\\<node>\..." form.
string trimmed = subscription.TrimStart('\\');
int slash = trimmed.IndexOf('\\');
if (slash > 0)
{
node = trimmed.Substring(0, slash);
trimmed = trimmed.Substring(slash + 1);
}
int bang = trimmed.IndexOf('!');
if (bang > 0)
{
provider = trimmed.Substring(0, bang);
group = trimmed.Substring(bang + 1);
}
else
{
provider = trimmed;
}
}
System.Text.StringBuilder sb = new System.Text.StringBuilder();
sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
sb.Append("<QUERY>");
sb.Append("<NODE>").Append(node).Append("</NODE>");
sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
if (!string.IsNullOrEmpty(group))
{
sb.Append("<GROUP>").Append(group).Append("</GROUP>");
}
sb.Append("</QUERY>");
sb.Append("</QUERIES>");
return sb.ToString();
}
private static VBGUID ToVbGuid(Guid g)
{
byte[] bytes = g.ToByteArray();
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
VBGUID vb = new VBGUID
{
Data1 = BitConverter.ToInt32(bytes, 0),
Data2 = BitConverter.ToInt16(bytes, 4),
Data3 = BitConverter.ToInt16(bytes, 6),
Data4 = new byte[8],
};
Array.Copy(bytes, 8, vb.Data4, 0, 8);
return vb;
}
/// <inheritdoc />
public void Dispose()
{
Timer? timerToDispose;
wwAlarmConsumerClass? clientToDispose;
wwAlarmConsumerClass? ackClientToDispose;
lock (syncRoot)
{
if (disposed) return;
disposed = true;
timerToDispose = pollTimer;
pollTimer = null;
clientToDispose = client;
client = null;
ackClientToDispose = ackClient;
ackClient = null;
}
timerToDispose?.Dispose();
ReleaseConsumerCom(clientToDispose);
ReleaseConsumerCom(ackClientToDispose);
}
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
{
if (consumer is null) return;
try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
if (Marshal.IsComObject(consumer))
{
try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
}
}
}
+4 -8
View File
@@ -24,15 +24,11 @@
<Private>false</Private> <Private>false</Private>
<SpecificVersion>false</SpecificVersion> <SpecificVersion>false</SpecificVersion>
</Reference> </Reference>
<Reference Include="aaAlarmManagedClient"> <Reference Include="Interop.WNWRAPCONSUMERLib">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath> <HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
<Private>false</Private> <Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="IAlarmMgrDataProvider">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
<Private>false</Private>
<SpecificVersion>false</SpecificVersion> <SpecificVersion>false</SpecificVersion>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference> </Reference>
</ItemGroup> </ItemGroup>