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
This commit was merged in pull request #118.
This commit is contained in:
2026-05-01 12:31:27 -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;
SubscribeBulkCommand subscribe_bulk = 32;
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;
GetSessionStateCommand get_session_state = 101;
GetWorkerInfoCommand get_worker_info = 102;
@@ -122,6 +127,11 @@ enum MxCommandKind {
MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK = 22;
MX_COMMAND_KIND_SUBSCRIBE_BULK = 23;
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_GET_SESSION_STATE = 101;
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
@@ -263,6 +273,63 @@ message SubscribeBulkCommand {
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 {
int32 server_handle = 1;
repeated int32 item_handles = 2;
@@ -314,6 +381,8 @@ message MxCommandReply {
BulkSubscribeReply un_advise_item_bulk = 31;
BulkSubscribeReply subscribe_bulk = 32;
BulkSubscribeReply unsubscribe_bulk = 33;
AcknowledgeAlarmReplyPayload acknowledge_alarm = 34;
QueryActiveAlarmsReplyPayload query_active_alarms = 35;
SessionStateReply session_state = 100;
WorkerInfoReply worker_info = 101;
DrainEventsReply drain_events = 102;
@@ -379,6 +448,24 @@ message DrainEventsReply {
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 {
MxEventFamily family = 1;
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.
/// </summary>
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();
_metrics.SessionOpened();
await TryAutoSubscribeAlarmsAsync(session, cancellationToken).ConfigureAwait(false);
return session;
}
catch (Exception exception)
@@ -396,4 +398,101 @@ public sealed class SessionManager : ISessionManager
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<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
services.AddSingleton<ISessionManager, SessionManager>();
services.AddSingleton<IAlarmRpcDispatcher, WorkerAlarmRpcDispatcher>();
services.AddHostedService<SessionLeaseMonitorHostedService>();
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;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// PR A.5 — pins the reference-composition logic used to translate AVEVA
/// AlarmRecord events into proto-friendly fields. Transition-kind mapping
/// (a trivial 4-line switch over <c>eAlmTransitions</c>) is verified on
/// the dev rig as part of the live alarm-event smoke test rather than
/// as a unit test, because the AVEVA-licensed enum assembly is
/// <c>Private=false</c> on the reference and is not copied to the test
/// bin directory.
/// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML
/// payloads into proto-friendly fields. The COM-side I/O on
/// <see cref="WnWrapAlarmConsumer"/> needs an AVEVA install and is
/// covered by the Skip-gated probe (<c>WnWrapConsumerProbeTests</c>);
/// these unit tests cover everything that doesn't touch the live COM
/// surface.
/// </summary>
public sealed class AlarmRecordTransitionMapperTests
{
[Fact]
public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
{
@@ -47,4 +47,76 @@ public sealed class AlarmRecordTransitionMapperTests
providerName: null, groupName: null, alarmName: "Bare");
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" />
</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>
@@ -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 AlarmMgrDataProviderCOM;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — translation helpers between AVEVA's
/// <see cref="eAlmTransitions"/> enum and the proto's
/// <see cref="AlarmTransitionKind"/>, plus alarm-reference composition.
/// Translation helpers between the wnwrapConsumer XML payload and the
/// proto-friendly <see cref="AlarmTransitionKind"/> wire format, plus
/// alarm-reference composition.
/// </summary>
/// <remarks>
/// <para>
/// The full <see cref="AlarmRecord"/> → proto-fields decoder lives
/// in <see cref="AlarmClientConsumer"/>. The two pieces that don't
/// need hardware validation (transition-kind enum mapping +
/// provider/group/name → reference string format) live here so the
/// consumer's hot-path stays focused on COM-side field access.
/// These mappings stay pure and library-agnostic so they're unit
/// testable without an AVEVA install. The COM-side I/O lives on
/// <see cref="WnWrapAlarmConsumer"/>.
/// </para>
/// </remarks>
public static class AlarmRecordTransitionMapper
{
/// <summary>
/// Maps the AVEVA <see cref="eAlmTransitions"/> enum onto the proto's
/// <see cref="AlarmTransitionKind"/>. Transitions outside the four
/// primary kinds (raise/ack/clear/retrigger) collapse to
/// <see cref="AlarmTransitionKind.Unspecified"/> so the EventPump's
/// decoding-failure counter records them.
/// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
/// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
/// <see cref="MxAlarmStateKind.Unspecified"/>.
/// </summary>
public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native)
public static MxAlarmStateKind ParseStateKind(string? stateXml)
{
// ALM = active-raise, RTN = return-to-normal/clear, ACK = acknowledge.
// SUB / ENB / DIS / SUP / REL / REMOVE — substitute / enable / disable /
// 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)
if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
return stateXml!.Trim().ToUpperInvariant() switch
{
case eAlmTransitions.almRec_trans_ALM: return AlarmTransitionKind.Raise;
case eAlmTransitions.almRec_trans_ACK: return AlarmTransitionKind.Acknowledge;
case eAlmTransitions.almRec_trans_RTN: return AlarmTransitionKind.Clear;
default: return AlarmTransitionKind.Unspecified;
"UNACK_ALM" => MxAlarmStateKind.UnackAlm,
"ACK_ALM" => MxAlarmStateKind.AckAlm,
"UNACK_RTN" => MxAlarmStateKind.UnackRtn,
"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>
@@ -63,4 +94,90 @@ public static class AlarmRecordTransitionMapper
? $"{provider}!{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 AlarmMgrDataProviderCOM;
using System.Collections.Generic;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.5 — abstraction over <c>aaAlarmManagedClient.AlarmClient</c>'s
/// subscribe / event-receive surface. The production implementation
/// (<see cref="AlarmClientConsumer"/>) wraps the AVEVA managed client;
/// tests substitute a fake to exercise the wiring against canned
/// <see cref="AlarmRecord"/> events without a live Galaxy.
/// Abstraction over an AVEVA alarm-consumer COM library. The production
/// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
/// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
/// tests substitute a fake to drive transition events without a live
/// Galaxy.
/// </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
{
/// <summary>
/// Fires once per alarm record the AVEVA alarm provider emits. The
/// subscriber is expected to forward each record to a transition mapper
/// and then onto the worker's event queue. Fired on the alarm-client's
/// internal callback thread; subscribers that need STA affinity must
/// marshal back themselves.
/// Fires once per detected alarm-state transition (raise, acknowledge,
/// clear, or new-alarm-already-acked-on-arrival). Subscribers are
/// expected to translate the record into the proto family
/// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
/// polling thread (the worker's STA in production); subscribers that
/// need a different thread must marshal back themselves.
/// </summary>
event EventHandler<AlarmRecord>? AlarmRecordReceived;
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>
/// Initializes the AVEVA alarm-client connection and subscribes to the
/// supplied alarm-provider expression. Subscription string follows
/// AVEVA's syntax (e.g. <c>"\Galaxy!OperationsRoom.AlarmGroup"</c> or
/// <c>"\\GR1\Galaxy!"</c> for a whole Galaxy).
/// Initializes the AVEVA alarm-client connection, registers as a
/// consumer, and subscribes to the supplied alarm-provider expression.
/// Subscription string follows AVEVA's canonical format:
/// <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>
void Subscribe(string subscription);
/// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches the AVEVA alarm provider's native ack API
/// (<c>AlarmAckByGUID</c>); operator user / node / domain / full-name
/// and the comment land atomically with the ack transition in the
/// alarm-history log.
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
/// user / node / domain / full-name and the comment land atomically
/// with the ack transition in the alarm-history log.
/// </summary>
int AcknowledgeByGuid(
Guid alarmGuid,
@@ -45,10 +62,27 @@ public interface IMxAccessAlarmConsumer : IDisposable
string ackOperatorFullName);
/// <summary>
/// Walks the currently-active alarm set and yields each as an
/// <see cref="AlarmRecord"/>. Used by the gateway's QueryActiveAlarms
/// (PR A.7) ConditionRefresh path — operator clients call this after
/// reconnect to seed local Part 9 state.
/// Acknowledge a single alarm by its (name, provider, group) tuple.
/// Reaches AVEVA's <c>AlarmAckByName</c> on
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the
/// human-readable reference but not the canonical GUID.
/// </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;
/// <summary>
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// Sink for native MxAccess alarm transitions. Bridges
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Architecture (pinned 2026-04-30):</strong> the worker hosts
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET Framework
/// 4.8 — the worker's existing runtime — and both use the same Windows
/// STA + WM_APP message pump. The MxAccess COM Toolkit at
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
/// exposes no alarm events; the alarm provider lives in a separate
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to.
/// </para>
/// <para>
/// <strong>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.
/// The dispatcher subscribes the consumer's
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
/// to <see cref="EnqueueTransition"/> at session attach time. The
/// <see cref="Attach"/> override here is a stub kept for the data-
/// session shape; the actual wire-up between consumer and sink
/// lives in the A.3 dispatcher (one step up the stack). Captured
/// payload schema and consumer threading discipline are described in
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
/// </para>
/// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
@@ -13,13 +13,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
{
private readonly MxAccessSession session;
private readonly VariantConverter variantConverter;
private readonly IAlarmCommandHandler? alarmCommandHandler;
/// <summary>
/// Initializes a command executor with an MXAccess session.
/// </summary>
/// <param name="session">MXAccess session on the STA thread.</param>
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(
MxAccessSession session,
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.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.alarmCommandHandler = alarmCommandHandler;
}
/// <summary>
@@ -64,6 +80,11 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command),
MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(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}."),
};
}
@@ -280,6 +301,201 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
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)
{
return new MxCommandReply
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.MxAccess;
@@ -14,8 +15,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessEventQueue eventQueue;
private readonly StaRuntime staRuntime;
private readonly Func<MxAccessEventQueue, IAlarmCommandHandler>? alarmCommandHandlerFactory;
private StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session;
private IAlarmCommandHandler? alarmCommandHandler;
private bool disposed;
/// <summary>
@@ -69,11 +72,29 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
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.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
}
/// <summary>
@@ -117,9 +138,16 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
}
session = MxAccessSession.Create(factory, eventSink, sessionId);
if (alarmCommandHandlerFactory is not null)
{
alarmCommandHandler = alarmCommandHandlerFactory(eventQueue);
}
commandDispatcher = new StaCommandDispatcher(
staRuntime,
new MxAccessCommandExecutor(session));
new MxAccessCommandExecutor(
session,
new VariantConverter(),
alarmCommandHandler));
return session.CreateWorkerReady(workerProcessId);
},
@@ -279,6 +307,27 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
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();
MxAccessShutdownResult result;
if (session is null)
@@ -333,6 +382,19 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
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)
{
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>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="aaAlarmManagedClient">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
<Private>false</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>
<Reference Include="Interop.WNWRAPCONSUMERLib">
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
<Private>true</Private>
<SpecificVersion>false</SpecificVersion>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference>
</ItemGroup>