alarms-over-gateway: full pipeline (wnwrap consumer + dispatcher + IPC + auto-subscribe + ack-by-name + live smoke) #118
@@ -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>\\<machine>\Galaxy!<area></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>\\<MachineName>\Galaxy!<DefaultArea></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>\\<machine>\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 `\\<machine>\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>() => 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>\\<machine>\Galaxy!<area></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>\\<node>\Galaxy!<area></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>\\<machine>\Galaxy!<area></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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user