A.2: replace AlarmClientConsumer with wnwrap-based polling consumer
Switch the worker's alarm-consumer surface from `aaAlarmManagedClient.AlarmClient` to `WNWRAPCONSUMERLib.wwAlarmConsumerClass` (CLSID 7AB52E5F-…) hosted by `wnwrapConsumer.dll`. The new path returns alarm records as a BSTR XML payload via `GetXmlCurrentAlarms2`, bypassing the FILETIME→DateTime auto-marshaling that crashed `GetHighPriAlarm` with ArgumentOutOfRangeException on every poll. Live captured 60/60 polls clean against `\DESKTOP-6JL3KKO\Galaxy!DEV` while a System Platform script flipped TestMachine_001.TestAlarm001 every 10s; the GUID, priority, state (UNACK_ALM ↔ UNACK_RTN), and ASCII-formatted timestamps arrived end-to-end. Implementation: - `Interop.WNWRAPCONSUMERLib.dll` generated via tlbimp, checked in under `lib/` so dev boxes don't need the SDK to build. - New `WnWrapAlarmConsumer` (replaces `AlarmClientConsumer`): owns a 500ms polling timer, parses `GetXmlCurrentAlarms2` output, diffs the snapshot keyed by alarm GUID, and raises one `MxAlarmTransitionEvent` per state change. Includes the Initialize→Register-before-Subscribe ordering fix found during Discovery probe runs. - New library-agnostic types `MxAlarmSnapshotRecord` / `MxAlarmStateKind` / `MxAlarmTransitionEvent` so the proto-build path is testable without an AVEVA install. - `AlarmRecordTransitionMapper` retired the COM-coupled `MapTransitionKind(eAlmTransitions)`; new pure helpers `ParseStateKind`, `MapTransition(prev, curr)`, and `ParseTransitionTimestampUtc` cover XML decode + state-delta logic. - `IMxAccessAlarmConsumer` event surface changed from `EventHandler<AlarmRecord>` to `EventHandler<MxAlarmTransitionEvent>` and `SnapshotActiveAlarms()` returns `MxAlarmSnapshotRecord` — decoupling the interface from any specific COM library. - Worker csproj drops `aaAlarmManagedClient` / `IAlarmMgrDataProvider` refs; adds `Interop.WNWRAPCONSUMERLib`. Tests: - 36 new unit tests (state-string mapping, prev/current → proto kind decision table, timestamp UTC reassembly, XML payload parser, 32-char hex GUID round-trip) covering everything that doesn't touch the live COM surface — all passing. - Skip-gated `WnWrapConsumerProbeTests.ProbeWnWrapConsumer` archives the live capture flow for regression / future probes. Docs: - `docs/AlarmClientDiscovery.md` "Option A — captured" section records sample XML payloads, the mangled `SetXmlAlarmQuery` round-trip (prefer `Subscribe` for filtering), the `GetStatistics` AccessViolationException quirk, and the worker-integration outline. Pre-existing failure noted (separate): `MxAccessInteropReference_ExistsOnlyInWorkerProject` was already failing on HEAD — the test project still references `ArchestrA.MxAccess` for the Skip-gated discovery probes. Not regressed by this change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -492,3 +492,199 @@ Skip-gated test:
|
|||||||
PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms`
|
PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms`
|
||||||
are correct — they're pull-style and don't depend on the
|
are correct — they're pull-style and don't depend on the
|
||||||
notification mechanism.
|
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.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,19 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Worker.MxAccess;
|
using MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
namespace MxGateway.Worker.Tests.MxAccess;
|
namespace MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR A.5 — pins the reference-composition logic used to translate AVEVA
|
/// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML
|
||||||
/// AlarmRecord events into proto-friendly fields. Transition-kind mapping
|
/// payloads into proto-friendly fields. The COM-side I/O on
|
||||||
/// (a trivial 4-line switch over <c>eAlmTransitions</c>) is verified on
|
/// <see cref="WnWrapAlarmConsumer"/> needs an AVEVA install and is
|
||||||
/// the dev rig as part of the live alarm-event smoke test rather than
|
/// covered by the Skip-gated probe (<c>WnWrapConsumerProbeTests</c>);
|
||||||
/// as a unit test, because the AVEVA-licensed enum assembly is
|
/// these unit tests cover everything that doesn't touch the live COM
|
||||||
/// <c>Private=false</c> on the reference and is not copied to the test
|
/// surface.
|
||||||
/// bin directory.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AlarmRecordTransitionMapperTests
|
public sealed class AlarmRecordTransitionMapperTests
|
||||||
{
|
{
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
|
public void ComposeFullReference_uses_provider_bang_group_dot_name_format()
|
||||||
{
|
{
|
||||||
@@ -47,4 +47,76 @@ public sealed class AlarmRecordTransitionMapperTests
|
|||||||
providerName: null, groupName: null, alarmName: "Bare");
|
providerName: null, groupName: null, alarmName: "Bare");
|
||||||
Assert.Equal("Bare", reference);
|
Assert.Equal("Bare", reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)]
|
||||||
|
[InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)]
|
||||||
|
[InlineData("UNACK_RTN", MxAlarmStateKind.UnackRtn)]
|
||||||
|
[InlineData("ACK_RTN", MxAlarmStateKind.AckRtn)]
|
||||||
|
[InlineData("unack_alm", MxAlarmStateKind.UnackAlm)] // case-insensitive
|
||||||
|
[InlineData(" ACK_ALM ", MxAlarmStateKind.AckAlm)] // trim
|
||||||
|
[InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)]
|
||||||
|
[InlineData("", MxAlarmStateKind.Unspecified)]
|
||||||
|
[InlineData(null, MxAlarmStateKind.Unspecified)]
|
||||||
|
public void ParseStateKind_decodes_state_strings(string? input, MxAlarmStateKind expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// First sighting: new alarm in *_ALM → Raise.
|
||||||
|
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
|
||||||
|
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Raise)]
|
||||||
|
// First sighting in *_RTN → Clear (unusual; missed the original raise).
|
||||||
|
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
|
||||||
|
[InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
|
||||||
|
// Active → Cleared.
|
||||||
|
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)]
|
||||||
|
[InlineData(MxAlarmStateKind.AckAlm, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)]
|
||||||
|
// Cleared → Active (re-trigger).
|
||||||
|
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
|
||||||
|
[InlineData(MxAlarmStateKind.AckRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)]
|
||||||
|
// Unacked → Acked (operator ack).
|
||||||
|
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)]
|
||||||
|
[InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Acknowledge)]
|
||||||
|
// No-op (state unchanged) — caller is supposed to filter these out.
|
||||||
|
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)]
|
||||||
|
// Current=Unspecified → Unspecified.
|
||||||
|
[InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)]
|
||||||
|
public void MapTransition_decides_proto_kind(
|
||||||
|
MxAlarmStateKind previous,
|
||||||
|
MxAlarmStateKind current,
|
||||||
|
AlarmTransitionKind expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseTransitionTimestampUtc_assembles_utc_from_xml_fields()
|
||||||
|
{
|
||||||
|
// Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0.
|
||||||
|
// Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC.
|
||||||
|
DateTime utc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
||||||
|
"2026/5/1", "13:26:14.709", gmtOffsetMinutes: 240, dstAdjustMinutes: 0);
|
||||||
|
|
||||||
|
Assert.Equal(DateTimeKind.Utc, utc.Kind);
|
||||||
|
Assert.Equal(2026, utc.Year);
|
||||||
|
Assert.Equal(5, utc.Month);
|
||||||
|
Assert.Equal(1, utc.Day);
|
||||||
|
Assert.Equal(17, utc.Hour);
|
||||||
|
Assert.Equal(26, utc.Minute);
|
||||||
|
Assert.Equal(14, utc.Second);
|
||||||
|
Assert.Equal(709, utc.Millisecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseTransitionTimestampUtc_returns_min_value_on_unparseable_inputs()
|
||||||
|
{
|
||||||
|
Assert.Equal(DateTime.MinValue,
|
||||||
|
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0));
|
||||||
|
Assert.Equal(DateTime.MinValue,
|
||||||
|
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("not a date", "13:00:00", 0, 0));
|
||||||
|
Assert.Equal(DateTime.MinValue,
|
||||||
|
AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("2026/5/1", "not a time", 0, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit-test coverage for <see cref="WnWrapAlarmConsumer"/>'s pure
|
||||||
|
/// parsing helpers — XML payload → <see cref="MxAlarmSnapshotRecord"/>
|
||||||
|
/// dictionary, and the 32-char-hex GUID round-trip. The COM-side
|
||||||
|
/// polling loop is verified separately by the Skip-gated
|
||||||
|
/// <c>WnWrapConsumerProbeTests</c> on a live AVEVA install.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WnWrapAlarmConsumerXmlTests
|
||||||
|
{
|
||||||
|
/// <summary>Captured XML from the dev rig (probe run 2026-05-01).</summary>
|
||||||
|
private const string SingleAlarmActiveXml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
||||||
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
||||||
|
"<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>" +
|
||||||
|
"<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
|
||||||
|
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE>" +
|
||||||
|
"<PROVIDER_NAME>Galaxy</PROVIDER_NAME>" +
|
||||||
|
"<GROUP>TestArea</GROUP>" +
|
||||||
|
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME>" +
|
||||||
|
"<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
|
||||||
|
"<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>" +
|
||||||
|
"<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
|
||||||
|
"<ALARM_COMMENT>Test alarm #1</ALARM_COMMENT></ALARM>" +
|
||||||
|
"</ALARM_RECORDS>";
|
||||||
|
|
||||||
|
private const string EmptyXml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"0\"></ALARM_RECORDS>";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_returns_empty_dictionary_for_empty_payload()
|
||||||
|
{
|
||||||
|
var records = WnWrapAlarmConsumer.ParseSnapshotXml(EmptyXml);
|
||||||
|
Assert.Empty(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_returns_empty_dictionary_for_null_or_whitespace()
|
||||||
|
{
|
||||||
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(""));
|
||||||
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_decodes_single_active_alarm_record()
|
||||||
|
{
|
||||||
|
var records = WnWrapAlarmConsumer.ParseSnapshotXml(SingleAlarmActiveXml);
|
||||||
|
|
||||||
|
Assert.Single(records);
|
||||||
|
Guid expectedGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
|
||||||
|
var record = records[expectedGuid];
|
||||||
|
Assert.Equal(expectedGuid, record.AlarmGuid);
|
||||||
|
Assert.Equal("DESKTOP-6JL3KKO", record.ProviderNode);
|
||||||
|
Assert.Equal("Galaxy", record.ProviderName);
|
||||||
|
Assert.Equal("TestArea", record.Group);
|
||||||
|
Assert.Equal("TestMachine_001.TestAlarm001", record.TagName);
|
||||||
|
Assert.Equal("DSC", record.Type);
|
||||||
|
Assert.Equal("true", record.Value);
|
||||||
|
Assert.Equal("true", record.Limit);
|
||||||
|
Assert.Equal(500, record.Priority);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
||||||
|
Assert.Equal("Test alarm #1", record.AlarmComment);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, record.TransitionTimestampUtc.Kind);
|
||||||
|
// 13:26:14.709 EDT (UTC-4, DSTADJUST=0) + 240 minutes = 17:26:14.709 UTC.
|
||||||
|
Assert.Equal(17, record.TransitionTimestampUtc.Hour);
|
||||||
|
Assert.Equal(26, record.TransitionTimestampUtc.Minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_silently_drops_records_with_invalid_guids()
|
||||||
|
{
|
||||||
|
string xml = SingleAlarmActiveXml.Replace(
|
||||||
|
"<GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>",
|
||||||
|
"<GUID>not-a-guid</GUID>");
|
||||||
|
Assert.Empty(WnWrapAlarmConsumer.ParseSnapshotXml(xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("BCC4705395424D65BDAABCDEA6A32A73", "BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||||
|
[InlineData("00000000000000000000000000000000", "00000000-0000-0000-0000-000000000000")]
|
||||||
|
public void TryParseHexGuid_handles_dashless_32_char_hex(string hex, string expected)
|
||||||
|
{
|
||||||
|
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||||
|
Assert.Equal(new Guid(expected), guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")]
|
||||||
|
public void TryParseHexGuid_accepts_canonical_dashed_form(string canonical)
|
||||||
|
{
|
||||||
|
Assert.True(WnWrapAlarmConsumer.TryParseHexGuid(canonical, out Guid guid));
|
||||||
|
Assert.Equal(new Guid(canonical), guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("nope")]
|
||||||
|
[InlineData("0123456789ABCDEF")] // too short
|
||||||
|
[InlineData("BCC4705395424D65BDAABCDEA6A32A73XX")] // too long
|
||||||
|
public void TryParseHexGuid_rejects_invalid_input(string? hex)
|
||||||
|
{
|
||||||
|
Assert.False(WnWrapAlarmConsumer.TryParseHexGuid(hex, out Guid guid));
|
||||||
|
Assert.Equal(Guid.Empty, guid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
<Private>true</Private>
|
<Private>true</Private>
|
||||||
<SpecificVersion>false</SpecificVersion>
|
<SpecificVersion>false</SpecificVersion>
|
||||||
</Reference>
|
</Reference>
|
||||||
|
<Reference Include="Interop.WNWRAPCONSUMERLib">
|
||||||
|
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
|
||||||
|
<Private>true</Private>
|
||||||
|
<SpecificVersion>false</SpecificVersion>
|
||||||
|
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||||
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using WNWRAPCONSUMERLib;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime probe — instantiate AVEVA's standalone wnwrapConsumer COM
|
||||||
|
/// class (CLSID 7AB52E5F-36B2-4A30-AE46-952A746F667C, registered at
|
||||||
|
/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll),
|
||||||
|
/// subscribe to the dev rig's `\\<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,191 +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>
|
|
||||||
/// <strong>⚠ Architecture finding (2026-05-01 reflection probe —
|
|
||||||
/// see <c>docs/AlarmClientDiscovery.md</c>):</strong> contrary to the
|
|
||||||
/// original PR A.5 design, <c>aaAlarmManagedClient.AlarmClient</c>
|
|
||||||
/// exposes <em>zero</em> public events on the deployed assembly
|
|
||||||
/// (<c>aaAlarmManagedClient.dll</c> v1.0.7368.41290). There is no
|
|
||||||
/// managed event surface. <c>RegisterConsumer(hWnd, …)</c> takes a
|
|
||||||
/// window handle because the actual notification mechanism is
|
|
||||||
/// WM_APP-pump messaging — AVEVA's alarm provider WM_APP-pokes the
|
|
||||||
/// registered window, and the consumer pulls the change set via
|
|
||||||
/// <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each poke.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// As a result, <see cref="AlarmRecordReceived"/> has no production
|
|
||||||
/// caller — <see cref="RaiseAlarmRecordReceived"/> is invoked only
|
|
||||||
/// from tests. <see cref="Subscribe"/> currently calls
|
|
||||||
/// <c>RegisterConsumer(hWnd: 0, …)</c> + <c>Subscribe(…)</c> and
|
|
||||||
/// returns OK, but no notifications will arrive at runtime because
|
|
||||||
/// no window is attached. Until A.2's WM_APP pump lands, the
|
|
||||||
/// gateway's <c>MX_EVENT_FAMILY_ON_ALARM_TRANSITION</c> family
|
|
||||||
/// cannot carry any events.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="Subscribe"/> as written is still a load-bearing call
|
|
||||||
/// the WM_APP path needs (it tells the alarm provider which
|
|
||||||
/// subscription expression to scope notifications to). The wiring
|
|
||||||
/// that has to change is the notification-receive side: replace the
|
|
||||||
/// <c>hWnd: 0</c> default with a real hidden message-only window
|
|
||||||
/// hWnd owned by the worker's STA, and add a <c>WindowProc</c> that
|
|
||||||
/// routes the AVEVA WM_APP message into a change-pull path that
|
|
||||||
/// ultimately invokes <see cref="RaiseAlarmRecordReceived"/>.
|
|
||||||
/// <see cref="AcknowledgeByGuid"/> and <see cref="SnapshotActiveAlarms"/>
|
|
||||||
/// are pull-style and don't depend on the event surface — they're
|
|
||||||
/// correct as is.
|
|
||||||
/// </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: placeholder — the AVEVA alarm provider notifies via
|
|
||||||
// WM_APP messages, not via a managed-client event surface (see
|
|
||||||
// docs/AlarmClientDiscovery.md, 2026-05-01 reflection probe).
|
|
||||||
// RegisterConsumer accepts hWnd=0 but no notifications will reach
|
|
||||||
// this consumer until the WM_APP pump lands as part of A.2 and
|
|
||||||
// a real message-only window's handle is supplied here.
|
|
||||||
int registerResult = client.RegisterConsumer(
|
|
||||||
hWnd: 0,
|
|
||||||
szProductName: DefaultProductName,
|
|
||||||
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 { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,77 @@
|
|||||||
using System;
|
using System;
|
||||||
using AlarmMgrDataProviderCOM;
|
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
namespace MxGateway.Worker.MxAccess;
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR A.5 — translation helpers between AVEVA's
|
/// Translation helpers between the wnwrapConsumer XML payload and the
|
||||||
/// <see cref="eAlmTransitions"/> enum and the proto's
|
/// proto-friendly <see cref="AlarmTransitionKind"/> wire format, plus
|
||||||
/// <see cref="AlarmTransitionKind"/>, plus alarm-reference composition.
|
/// alarm-reference composition.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The full <see cref="AlarmRecord"/> → proto-fields decoder lives
|
/// These mappings stay pure and library-agnostic so they're unit
|
||||||
/// in <see cref="AlarmClientConsumer"/>. The two pieces that don't
|
/// testable without an AVEVA install. The COM-side I/O lives on
|
||||||
/// need hardware validation (transition-kind enum mapping +
|
/// <see cref="WnWrapAlarmConsumer"/>.
|
||||||
/// provider/group/name → reference string format) live here so the
|
|
||||||
/// consumer's hot-path stays focused on COM-side field access.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class AlarmRecordTransitionMapper
|
public static class AlarmRecordTransitionMapper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the AVEVA <see cref="eAlmTransitions"/> enum onto the proto's
|
/// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
|
||||||
/// <see cref="AlarmTransitionKind"/>. Transitions outside the four
|
/// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
|
||||||
/// primary kinds (raise/ack/clear/retrigger) collapse to
|
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
|
||||||
/// <see cref="AlarmTransitionKind.Unspecified"/> so the EventPump's
|
/// <see cref="MxAlarmStateKind.Unspecified"/>.
|
||||||
/// decoding-failure counter records them.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native)
|
public static MxAlarmStateKind ParseStateKind(string? stateXml)
|
||||||
{
|
{
|
||||||
// ALM = active-raise, RTN = return-to-normal/clear, ACK = acknowledge.
|
if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
|
||||||
// SUB / ENB / DIS / SUP / REL / REMOVE — substitute / enable / disable /
|
return stateXml!.Trim().ToUpperInvariant() switch
|
||||||
// suppress / release / remove. None of those map to OPC UA Part 9
|
|
||||||
// transitions today; future work could add a Substituted / Suppressed
|
|
||||||
// proto kind if a customer needs it.
|
|
||||||
switch (native)
|
|
||||||
{
|
{
|
||||||
case eAlmTransitions.almRec_trans_ALM: return AlarmTransitionKind.Raise;
|
"UNACK_ALM" => MxAlarmStateKind.UnackAlm,
|
||||||
case eAlmTransitions.almRec_trans_ACK: return AlarmTransitionKind.Acknowledge;
|
"ACK_ALM" => MxAlarmStateKind.AckAlm,
|
||||||
case eAlmTransitions.almRec_trans_RTN: return AlarmTransitionKind.Clear;
|
"UNACK_RTN" => MxAlarmStateKind.UnackRtn,
|
||||||
default: return AlarmTransitionKind.Unspecified;
|
"ACK_RTN" => MxAlarmStateKind.AckRtn,
|
||||||
|
_ => MxAlarmStateKind.Unspecified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decide which proto transition kind a state change represents.
|
||||||
|
/// The decision table:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>previous=Unspecified</c> + <c>current=*Alm</c> → Raise (new alarm).</description></item>
|
||||||
|
/// <item><description><c>previous=Unspecified</c> + <c>current=*Rtn</c> → Clear (alarm appeared in cleared state — rare; missed the raise).</description></item>
|
||||||
|
/// <item><description><c>previous=Unack*</c> + <c>current=Ack*</c> → Acknowledge.</description></item>
|
||||||
|
/// <item><description><c>previous=*Alm</c> + <c>current=*Rtn</c> → Clear.</description></item>
|
||||||
|
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
|
||||||
|
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static AlarmTransitionKind MapTransition(
|
||||||
|
MxAlarmStateKind previous,
|
||||||
|
MxAlarmStateKind current)
|
||||||
|
{
|
||||||
|
if (current == MxAlarmStateKind.Unspecified) return AlarmTransitionKind.Unspecified;
|
||||||
|
|
||||||
|
bool currentIsAlm = current is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||||
|
bool currentIsRtn = current is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||||
|
bool currentIsAcked = current is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||||
|
|
||||||
|
if (previous == MxAlarmStateKind.Unspecified)
|
||||||
|
{
|
||||||
|
return currentIsAlm ? AlarmTransitionKind.Raise : AlarmTransitionKind.Clear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool previousIsAlm = previous is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||||
|
bool previousIsRtn = previous is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||||
|
bool previousIsAcked = previous is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||||
|
|
||||||
|
if (previousIsAlm && currentIsRtn) return AlarmTransitionKind.Clear;
|
||||||
|
if (previousIsRtn && currentIsAlm) return AlarmTransitionKind.Raise;
|
||||||
|
if (!previousIsAcked && currentIsAcked) return AlarmTransitionKind.Acknowledge;
|
||||||
|
|
||||||
|
return AlarmTransitionKind.Unspecified;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,4 +94,90 @@ public static class AlarmRecordTransitionMapper
|
|||||||
? $"{provider}!{name}"
|
? $"{provider}!{name}"
|
||||||
: $"{provider}!{group}.{name}";
|
: $"{provider}!{group}.{name}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reassemble a UTC <see cref="DateTime"/> from the wnwrap XML's
|
||||||
|
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
||||||
|
/// fields. Returns <see cref="DateTime.MinValue"/> when DATE / TIME
|
||||||
|
/// can't be parsed (best-effort — failure is non-fatal; the proto
|
||||||
|
/// will carry the epoch and the EventQueue's fault counter records
|
||||||
|
/// the parse miss).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="xmlDate">e.g. <c>"2026/5/1"</c> (no zero-padding).</param>
|
||||||
|
/// <param name="xmlTime">e.g. <c>"13:26:14.709"</c>.</param>
|
||||||
|
/// <param name="gmtOffsetMinutes">Offset of the producer's local time vs UTC, in minutes.</param>
|
||||||
|
/// <param name="dstAdjustMinutes">DST adjustment already applied to local time, in minutes.</param>
|
||||||
|
public static DateTime ParseTransitionTimestampUtc(
|
||||||
|
string? xmlDate,
|
||||||
|
string? xmlTime,
|
||||||
|
int gmtOffsetMinutes,
|
||||||
|
int dstAdjustMinutes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(xmlDate) || string.IsNullOrWhiteSpace(xmlTime))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse DATE: yyyy/M/d (no zero padding observed). Use ParseExact with
|
||||||
|
// multiple format candidates — AVEVA's locale may format differently
|
||||||
|
// on non-en-US hosts.
|
||||||
|
string[] dateFormats =
|
||||||
|
{
|
||||||
|
"yyyy/M/d", "yyyy/MM/dd", "M/d/yyyy", "MM/dd/yyyy",
|
||||||
|
"d/M/yyyy", "dd/MM/yyyy",
|
||||||
|
};
|
||||||
|
string dateTrim = xmlDate!.Trim();
|
||||||
|
if (!DateTime.TryParseExact(
|
||||||
|
dateTrim,
|
||||||
|
dateFormats,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out DateTime date))
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(
|
||||||
|
dateTrim,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out date))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse TIME: H:m:s.fff (variable precision).
|
||||||
|
string[] timeFormats =
|
||||||
|
{
|
||||||
|
"H:m:s.fff", "H:m:s.ff", "H:m:s.f", "H:m:s",
|
||||||
|
"HH:mm:ss.fff", "HH:mm:ss.ff", "HH:mm:ss.f", "HH:mm:ss",
|
||||||
|
};
|
||||||
|
string timeTrim = xmlTime!.Trim();
|
||||||
|
if (!DateTime.TryParseExact(
|
||||||
|
timeTrim,
|
||||||
|
timeFormats,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out DateTime time))
|
||||||
|
{
|
||||||
|
if (!DateTime.TryParse(
|
||||||
|
timeTrim,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None,
|
||||||
|
out time))
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime localProducerTime = new DateTime(
|
||||||
|
date.Year, date.Month, date.Day,
|
||||||
|
time.Hour, time.Minute, time.Second, time.Millisecond,
|
||||||
|
DateTimeKind.Unspecified);
|
||||||
|
|
||||||
|
// GMTOFFSET = minutes east of UTC (or behind, depending on convention).
|
||||||
|
// The wnwrap convention observed: GMTOFFSET=240, DSTADJUST=0 for
|
||||||
|
// EDT (UTC-4) — so the field is "minutes from local to UTC". To get
|
||||||
|
// UTC, ADD the offset.
|
||||||
|
DateTime utc = localProducerTime.AddMinutes(gmtOffsetMinutes - dstAdjustMinutes);
|
||||||
|
return DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,57 @@
|
|||||||
using System;
|
using System;
|
||||||
using AlarmMgrDataProviderCOM;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace MxGateway.Worker.MxAccess;
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR A.5 — abstraction over <c>aaAlarmManagedClient.AlarmClient</c>'s
|
/// Abstraction over an AVEVA alarm-consumer COM library. The production
|
||||||
/// subscribe / event-receive surface. The production implementation
|
/// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
|
||||||
/// (<see cref="AlarmClientConsumer"/>) wraps the AVEVA managed client;
|
/// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
|
||||||
/// tests substitute a fake to exercise the wiring against canned
|
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
|
||||||
/// <see cref="AlarmRecord"/> events without a live Galaxy.
|
/// tests substitute a fake to drive transition events without a live
|
||||||
|
/// Galaxy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The receive surface is poll-based: the production consumer
|
||||||
|
/// periodically calls <c>GetXmlCurrentAlarms2</c>, parses the
|
||||||
|
/// returned XML payload, diffs against the previous snapshot keyed
|
||||||
|
/// by alarm GUID, and raises <see cref="AlarmTransitionEmitted"/>
|
||||||
|
/// once per state change. This bypasses the FILETIME marshaling
|
||||||
|
/// crash in <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>
|
||||||
|
/// (see <c>docs/AlarmClientDiscovery.md</c>) — XML strings carry
|
||||||
|
/// timestamps as ASCII fields, no DateTime auto-conversion happens
|
||||||
|
/// on the .NET interop boundary.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public interface IMxAccessAlarmConsumer : IDisposable
|
public interface IMxAccessAlarmConsumer : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires once per alarm record the AVEVA alarm provider emits. The
|
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
||||||
/// subscriber is expected to forward each record to a transition mapper
|
/// clear, or new-alarm-already-acked-on-arrival). Subscribers are
|
||||||
/// and then onto the worker's event queue. Fired on the alarm-client's
|
/// expected to translate the record into the proto family
|
||||||
/// internal callback thread; subscribers that need STA affinity must
|
/// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
|
||||||
/// marshal back themselves.
|
/// polling thread (the worker's STA in production); subscribers that
|
||||||
|
/// need a different thread must marshal back themselves.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
event EventHandler<AlarmRecord>? AlarmRecordReceived;
|
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the AVEVA alarm-client connection and subscribes to the
|
/// Initializes the AVEVA alarm-client connection, registers as a
|
||||||
/// supplied alarm-provider expression. Subscription string follows
|
/// consumer, and subscribes to the supplied alarm-provider expression.
|
||||||
/// AVEVA's syntax (e.g. <c>"\Galaxy!OperationsRoom.AlarmGroup"</c> or
|
/// Subscription string follows AVEVA's canonical format:
|
||||||
/// <c>"\\GR1\Galaxy!"</c> for a whole Galaxy).
|
/// <c>\\<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>
|
/// </summary>
|
||||||
void Subscribe(string subscription);
|
void Subscribe(string subscription);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Acknowledges a single alarm with full operator-identity fidelity.
|
/// Acknowledges a single alarm with full operator-identity fidelity.
|
||||||
/// Reaches the AVEVA alarm provider's native ack API
|
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
|
||||||
/// (<c>AlarmAckByGUID</c>); operator user / node / domain / full-name
|
/// user / node / domain / full-name and the comment land atomically
|
||||||
/// and the comment land atomically with the ack transition in the
|
/// with the ack transition in the alarm-history log.
|
||||||
/// alarm-history log.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
int AcknowledgeByGuid(
|
int AcknowledgeByGuid(
|
||||||
Guid alarmGuid,
|
Guid alarmGuid,
|
||||||
@@ -45,10 +62,10 @@ public interface IMxAccessAlarmConsumer : IDisposable
|
|||||||
string ackOperatorFullName);
|
string ackOperatorFullName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Walks the currently-active alarm set and yields each as an
|
/// Returns the consumer's most recently parsed snapshot of currently
|
||||||
/// <see cref="AlarmRecord"/>. Used by the gateway's QueryActiveAlarms
|
/// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7)
|
||||||
/// (PR A.7) ConditionRefresh path — operator clients call this after
|
/// ConditionRefresh path — operator clients call this after reconnect
|
||||||
/// reconnect to seed local Part 9 state.
|
/// to seed local Part 9 state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
System.Collections.Generic.IReadOnlyList<AlarmRecord> SnapshotActiveAlarms();
|
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,70 +4,21 @@ using MxGateway.Contracts.Proto;
|
|||||||
namespace MxGateway.Worker.MxAccess;
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the
|
/// Sink for native MxAccess alarm transitions. Bridges
|
||||||
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's
|
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
|
||||||
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages
|
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
|
||||||
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
|
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <strong>Architecture (revised 2026-05-01 — see
|
/// The dispatcher subscribes the consumer's
|
||||||
/// <c>docs/AlarmClientDiscovery.md</c>):</strong> the worker hosts
|
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
|
||||||
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing
|
/// to <see cref="EnqueueTransition"/> at session attach time. The
|
||||||
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET
|
/// <see cref="Attach"/> override here is a stub kept for the data-
|
||||||
/// Framework 4.8. The MxAccess COM Toolkit at
|
/// session shape; the actual wire-up between consumer and sink
|
||||||
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
|
/// lives in the A.3 dispatcher (one step up the stack). Captured
|
||||||
/// exposes no alarm events; the alarm provider lives in a separate
|
/// payload schema and consumer threading discipline are described in
|
||||||
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to.
|
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <strong>Notification mechanism: WM_APP pump.</strong> A reflection
|
|
||||||
/// probe of <c>aaAlarmManagedClient.dll</c> (v1.0.7368.41290) on
|
|
||||||
/// 2026-05-01 confirmed the public <c>AlarmClient</c> class has zero
|
|
||||||
/// public events. The original PR A.5 design (managed-event surface,
|
|
||||||
/// no message pump) is incorrect against this assembly. AVEVA's
|
|
||||||
/// alarm provider WM_APP-pokes a window registered through
|
|
||||||
/// <c>RegisterConsumer(hWnd, …)</c>; the consumer pulls the change
|
|
||||||
/// set via <c>GetStatistics</c> + <c>GetAlarmExtendedRec</c> on each
|
|
||||||
/// poke. PR A.5's <see cref="AlarmClientConsumer"/> still owns the
|
|
||||||
/// <see cref="AlarmClient"/> handle and the
|
|
||||||
/// <see cref="AlarmClient.Subscribe"/> /
|
|
||||||
/// <see cref="AlarmClient.AlarmAckByGUID"/> pull-style calls; only
|
|
||||||
/// the receive path is wrong.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <strong>Discovered API surface</strong> (see
|
|
||||||
/// <c>AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface</c> in
|
|
||||||
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe; full
|
|
||||||
/// output captured in <c>docs/AlarmClientDiscovery.md</c>):
|
|
||||||
/// </para>
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><description><c>RegisterConsumer(hWnd, productName, applicationName, version, retainHidden)</c> — registers a Windows-message-pump consumer; the AVEVA alarm service WM_APP-pokes the hWnd when alarms change.</description></item>
|
|
||||||
/// <item><description><c>Subscribe(provider, fromPri, toPri, queryType, sortFlags, filterMask, filterSpec)</c> — subscribes to a Galaxy alarm provider with priority + filter scoping.</description></item>
|
|
||||||
/// <item><description><c>GetStatistics(out percentQuery, totalAlarms, activeAlarms, …, out int[] changeCodes, out int[] changePos, out int[] hAlarm)</c> — called on each WM_APP poke; enumerates which alarms changed.</description></item>
|
|
||||||
/// <item><description><c>GetAlarmExtendedRec(index, out AlarmRecord)</c> — pulls the full alarm record (operator, comment, original raise, category, severity).</description></item>
|
|
||||||
/// <item><description><c>AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode, oprDomain, oprFullName)</c> — full-fidelity native Acknowledge: comment + four operator-identity fields are atomic with the ack transition.</description></item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>
|
|
||||||
/// <strong>Open questions before A.2 implementation</strong>
|
|
||||||
/// (see <c>docs/AlarmClientDiscovery.md</c> "Implications for A.2"):
|
|
||||||
/// </para>
|
|
||||||
/// <list type="number">
|
|
||||||
/// <item><description>WM_APP message ID — not in the public surface, needs AVEVA C++ Toolkit reference or a runtime probe.</description></item>
|
|
||||||
/// <item><description><c>wParam</c> / <c>lParam</c> semantics — likely none (the pattern is "got poked → pull state via <c>GetStatistics</c>"), but confirm during the probe.</description></item>
|
|
||||||
/// <item><description>STA / threading affinity for the message-only window — likely the worker's existing STA, but if AVEVA assumes UI-thread inside <c>GetStatistics</c> the alarm path may need its own STA.</description></item>
|
|
||||||
/// <item><description>Subscription scope — reuse the configured Galaxy name from the data session.</description></item>
|
|
||||||
/// </list>
|
|
||||||
/// <para>
|
|
||||||
/// Until A.2 lands a hidden message-only window + WindowProc that
|
|
||||||
/// routes WM_APP into <see cref="EnqueueTransition"/>,
|
|
||||||
/// <see cref="Attach"/> is a no-op. The worker continues to function
|
|
||||||
/// for data subscriptions, and the gateway's
|
|
||||||
/// <see cref="MxEventFamily.OnAlarmTransition"/> family is reserved
|
|
||||||
/// on the wire but never emitted. lmxopcua-side
|
|
||||||
/// <c>AlarmConditionService</c> keeps the sub-attribute synthesis
|
|
||||||
/// active and continues to surface alarms to OPC UA Part 9 clients
|
|
||||||
/// in the meantime.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||||
|
|||||||
@@ -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,386 @@
|
|||||||
|
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 Timer? pollTimer;
|
||||||
|
private bool subscribed;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public WnWrapAlarmConsumer()
|
||||||
|
: this(new wwAlarmConsumerClass(), DefaultPollIntervalMilliseconds, DefaultMaxAlarmsPerFetch)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test seam — inject a pre-created COM client and tune the poll cadence.</summary>
|
||||||
|
internal WnWrapAlarmConsumer(
|
||||||
|
wwAlarmConsumerClass client,
|
||||||
|
int pollIntervalMilliseconds,
|
||||||
|
int maxAlarmsPerFetch)
|
||||||
|
{
|
||||||
|
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
this.pollIntervalMs = pollIntervalMilliseconds > 0
|
||||||
|
? pollIntervalMilliseconds
|
||||||
|
: DefaultPollIntervalMilliseconds;
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Per AlarmClientDiscovery.md: InitializeConsumer MUST precede
|
||||||
|
// RegisterConsumer for the alarm provider chain to become visible.
|
||||||
|
int init = com.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.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.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}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribed = true;
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
lock (syncRoot)
|
||||||
|
{
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
timerToDispose = pollTimer;
|
||||||
|
pollTimer = null;
|
||||||
|
clientToDispose = client;
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
timerToDispose?.Dispose();
|
||||||
|
if (clientToDispose is not null)
|
||||||
|
{
|
||||||
|
try { clientToDispose.DeregisterConsumer(); } catch { /* swallow */ }
|
||||||
|
try { clientToDispose.UninitializeConsumer(); } catch { /* swallow */ }
|
||||||
|
if (Marshal.IsComObject(clientToDispose))
|
||||||
|
{
|
||||||
|
try { Marshal.FinalReleaseComObject(clientToDispose); } catch { /* swallow */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,11 @@
|
|||||||
<Private>false</Private>
|
<Private>false</Private>
|
||||||
<SpecificVersion>false</SpecificVersion>
|
<SpecificVersion>false</SpecificVersion>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="aaAlarmManagedClient">
|
<Reference Include="Interop.WNWRAPCONSUMERLib">
|
||||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
|
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
|
||||||
<Private>false</Private>
|
<Private>true</Private>
|
||||||
<SpecificVersion>false</SpecificVersion>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="IAlarmMgrDataProvider">
|
|
||||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
<SpecificVersion>false</SpecificVersion>
|
<SpecificVersion>false</SpecificVersion>
|
||||||
|
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user