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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-01 09:44:15 -04:00
parent f490ae2593
commit f711a55be4
13 changed files with 1326 additions and 318 deletions
+196
View File
@@ -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 `\\&lt;machine&gt;\Galaxy!DEV` provider, and
/// poll <c>GetXmlCurrentAlarms2</c> while a System Platform script flips
/// <c>TestMachine_001.TestAlarm001</c> every 10s. The XML payload bypasses
/// the FILETIME→DateTime auto-marshaling that crashes
/// <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>.
///
/// Skip-gated; flip Skip=null to run on the dev rig.
/// </summary>
public sealed class WnWrapConsumerProbeTests
{
private static readonly string MachineName = Environment.MachineName;
private static readonly string SubscriptionExpression =
$@"\\{MachineName}\Galaxy!DEV";
// XML query form — per WIN-911 / ArchestrA reference. NODE is the
// machine, PROVIDER is the literal "Galaxy", GROUP is the area.
private static readonly string XmlAlarmQuery =
"<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">" +
"<QUERY>" +
$"<NODE>{Environment.MachineName}</NODE>" +
"<PROVIDER>Galaxy</PROVIDER>" +
"<GROUP>DEV</GROUP>" +
"</QUERY>" +
"</QUERIES>";
private const int MaxAlarmsPerFetch = 100;
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(30);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
private readonly ITestOutputHelper output;
private readonly ConcurrentQueue<string> log = new ConcurrentQueue<string>();
private readonly Stopwatch elapsed = Stopwatch.StartNew();
public WnWrapConsumerProbeTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
public void ProbeWnWrapConsumer()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
var thread = new Thread(() =>
{
try { RunProbe(); }
catch (Exception ex) { threadException = ex; }
finally { done.Set(); }
});
thread.IsBackground = false;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
done.Wait();
thread.Join();
output.WriteLine($"Captured {log.Count} log line(s):");
while (log.TryDequeue(out string? line))
{
output.WriteLine(line);
}
if (threadException != null)
{
throw threadException;
}
}
private void RunProbe()
{
wwAlarmConsumerClass? client = null;
try
{
Log("Creating wwAlarmConsumerClass via CoCreateInstance...");
client = new wwAlarmConsumerClass();
Log($"Instantiated. RuntimeType={client.GetType().FullName}");
// Lifecycle: per AlarmClientDiscovery.md finding, InitializeConsumer
// MUST precede RegisterConsumer for the alarm provider to become
// visible. The wnwrap surface mirrors that requirement.
try
{
int init = client.InitializeConsumer("MxGatewayProbe.WnWrap");
Log($"InitializeConsumer -> {init}");
}
catch (Exception ex)
{
Log($"InitializeConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
// hWnd=0 — XML pull-based; no message pump needed.
int reg = client.RegisterConsumer(
hWnd: 0,
szProductName: "MxGatewayProbe",
szApplicationName: "MxGatewayProbe.WnWrap",
szVersion: "1.0");
Log($"RegisterConsumer(hWnd=0) -> {reg}");
}
catch (Exception ex)
{
Log($"RegisterConsumer threw: {ex.GetType().Name}: {ex.Message}");
}
// Try both subscription mechanisms: classic Subscribe (canonical
// scope from prior aaAlarmManagedClient probe), and
// SetXmlAlarmQuery (the wnwrap-native filter format).
try
{
int sub = client.Subscribe(
szSubscription: SubscriptionExpression,
wFromPri: 1,
wToPri: 999,
QueryType: eQueryType.qtSummary,
SortFlags: eSortFlags.sfReturnNewestFirst,
FilterMask: eAlarmFilterState.asAlarmActiveNow,
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
Log($"Subscribe('{SubscriptionExpression}') -> {sub}");
}
catch (Exception ex)
{
Log($"Subscribe threw: {ex.GetType().Name}: {ex.Message}");
}
try
{
Log($"SetXmlAlarmQuery payload: {XmlAlarmQuery}");
client.SetXmlAlarmQuery(XmlAlarmQuery);
Log("SetXmlAlarmQuery -> ok");
}
catch (Exception ex)
{
Log($"SetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Echo the query back so we can confirm what the consumer is
// actually filtering on (provider may rewrite or reject some
// attributes silently).
try
{
object echo = string.Empty;
client.GetXmlAlarmQuery(out echo);
Log($"GetXmlAlarmQuery (round-trip) -> {Truncate(echo?.ToString() ?? "<null>", 600)}");
}
catch (Exception ex)
{
Log($"GetXmlAlarmQuery threw: {ex.GetType().Name}: {ex.Message}");
}
// Pump phase: poll GetXmlCurrentAlarms2 every PollInterval; log on
// every change in payload. Run for PumpDuration. The user's flip
// script writes TestMachine_001.TestAlarm001 every 10s; expect at
// least 2-3 transitions over a 30s window.
Log($"Polling GetXmlCurrentAlarms2 every {PollInterval.TotalMilliseconds:F0}ms for {PumpDuration.TotalSeconds:F0}s.");
DateTime deadline = DateTime.UtcNow + PumpDuration;
DateTime nextPoll = DateTime.UtcNow;
int pollCount = 0;
string lastV2 = string.Empty;
string lastV1 = string.Empty;
int v2Ok = 0, v2Throw = 0, v1Ok = 0, v1Throw = 0;
int statsOk = 0, statsThrow = 0;
string lastStats = string.Empty;
while (DateTime.UtcNow < deadline)
{
if (DateTime.UtcNow >= nextPoll)
{
pollCount++;
// V2 channel.
try
{
object xml2 = string.Empty;
client.GetXmlCurrentAlarms2(MaxAlarmsPerFetch, out xml2);
v2Ok++;
string s = xml2?.ToString() ?? "<null>";
if (s != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV2 = s;
}
}
catch (Exception ex)
{
v2Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV2)
{
Log($"GetXmlCurrentAlarms2 #{pollCount} threw: {es}");
lastV2 = es;
}
}
// V1 channel — different vtable slot; either may be the
// populated one in this AVEVA build.
try
{
object xml1 = string.Empty;
client.GetXmlCurrentAlarms(MaxAlarmsPerFetch, out xml1);
v1Ok++;
string s = xml1?.ToString() ?? "<null>";
if (s != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} (CHANGED, len={s.Length}): {Truncate(s, 1200)}");
lastV1 = s;
}
}
catch (Exception ex)
{
v1Throw++;
string es = $"{ex.GetType().Name}: {ex.Message}";
if (es != lastV1)
{
Log($"GetXmlCurrentAlarms #{pollCount} threw: {es}");
lastV1 = es;
}
}
// Stats channel — heartbeat + active-count even if the XML
// calls are dry, this surfaces whether wnwrap sees any
// alarms in the subscribed scope at all.
try
{
int pct, total, active, newAlms, changes;
client.GetStatistics(
out pct, out total, out active, out newAlms, out changes,
IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
statsOk++;
string statsSummary = $"pct={pct} total={total} active={active} new={newAlms} changes={changes}";
if (statsSummary != lastStats)
{
Log($"GetStatistics #{pollCount} (CHANGED): {statsSummary}");
lastStats = statsSummary;
}
}
catch (Exception ex)
{
statsThrow++;
Log($"GetStatistics #{pollCount} threw: {ex.GetType().Name}: {ex.Message}");
}
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(20);
}
Log($"Pump done. Tally: v2 ok={v2Ok} threw={v2Throw}, v1 ok={v1Ok} threw={v1Throw}, stats ok={statsOk} threw={statsThrow}");
try { int dereg = client.DeregisterConsumer(); Log($"DeregisterConsumer -> {dereg}"); }
catch (Exception ex) { Log($"DeregisterConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
try { int uninit = client.UninitializeConsumer(); Log($"UninitializeConsumer -> {uninit}"); }
catch (Exception ex) { Log($"UninitializeConsumer threw: {ex.GetType().Name}: {ex.Message}"); }
}
finally
{
if (client != null && Marshal.IsComObject(client))
{
try { Marshal.FinalReleaseComObject(client); } catch { /* swallow */ }
}
}
}
private void Log(string line)
{
log.Enqueue($"[t={elapsed.Elapsed.TotalSeconds:F3}s] {line}");
}
private static string Truncate(string s, int max)
{
if (string.IsNullOrEmpty(s) || s.Length <= max) return s ?? string.Empty;
return s.Substring(0, max) + $"…[+{s.Length - max} chars]";
}
}
@@ -1,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>\\&lt;node&gt;\Galaxy!&lt;area&gt;</c>. The literal "Galaxy" is
/// the provider name (regardless of the configured Galaxy database
/// name). Calling Subscribe also begins polling on the consumer's
/// internal timer.
/// </summary> /// </summary>
void Subscribe(string subscription); void Subscribe(string subscription);
/// <summary> /// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity. /// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches the AVEVA alarm provider's native ack API /// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
/// (<c>AlarmAckByGUID</c>); operator user / node / domain / full-name /// user / node / domain / full-name and the comment land atomically
/// and the comment land atomically with the ack transition in the /// with the ack transition in the alarm-history log.
/// alarm-history log.
/// </summary> /// </summary>
int AcknowledgeByGuid( int AcknowledgeByGuid(
Guid alarmGuid, Guid alarmGuid,
@@ -45,10 +62,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 */ }
}
}
}
}
+4 -8
View File
@@ -24,15 +24,11 @@
<Private>false</Private> <Private>false</Private>
<SpecificVersion>false</SpecificVersion> <SpecificVersion>false</SpecificVersion>
</Reference> </Reference>
<Reference Include="aaAlarmManagedClient"> <Reference Include="Interop.WNWRAPCONSUMERLib">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath> <HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
<Private>false</Private> <Private>true</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="IAlarmMgrDataProvider">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll</HintPath>
<Private>false</Private>
<SpecificVersion>false</SpecificVersion> <SpecificVersion>false</SpecificVersion>
<EmbedInteropTypes>false</EmbedInteropTypes>
</Reference> </Reference>
</ItemGroup> </ItemGroup>