diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index d9fe774..9ad4e54 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -492,3 +492,199 @@ Skip-gated test: PR A.5's `Subscribe` / `AcknowledgeByGuid` / `SnapshotActiveAlarms` are correct — they're pull-style and don't depend on the notification mechanism. + +## Option A — captured, 2026-05-01 + +`wnwrapConsumer.dll` (`C:\Program Files (x86)\Common Files\ +ArchestrA\wnwrapConsumer.dll`) hosts the standalone COM class +`WNWRAPCONSUMERLib.wwAlarmConsumerClass`. Type library imports +cleanly via `tlbimp` (output stored under `mxaccessgw/lib/ +Interop.WNWRAPCONSUMERLib.dll`). The COM class is registered in +`HKLM:\SOFTWARE\WOW6432Node\Classes\CLSID\ +{7AB52E5F-36B2-4A30-AE46-952A746F667C}` with `ThreadingModel= +Apartment` — `new wwAlarmConsumerClass()` succeeds via +`CoCreateInstance`. + +The probe `MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs` +(Skip-gated, archival) drove the captured run. Lifecycle: + +1. `new wwAlarmConsumerClass()` — instantiated. +2. `InitializeConsumer("MxGatewayProbe.WnWrap")` -> 0. +3. `RegisterConsumer(hWnd: 0, productName, applicationName, + version)` -> 0. **Note:** wnwrap's `RegisterConsumer` is + 4-arg (no `bRetainHiddenAlarms`); `aaAlarmManagedClient`'s + is 5-arg. Different surface. +4. `Subscribe(@"\\\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 + +``` + +With alarm active (`UNACK_ALM`, value=true after the flip +script set the bool true): + +```xml + + + + BCC4705395424D65BDAABCDEA6A32A73 + 2026/5/1 + + 240 + 0 + DESKTOP-6JL3KKO + Galaxy + TestArea + TestMachine_001.TestAlarm001 + DSC + true + true + 500 + UNACK_ALM + + + Test alarm #1 + + +``` + +After the script set the bool false (`UNACK_RTN`, value=false): + +```xml + + + + BCC4705395424D65BDAABCDEA6A32A73 + 2026/5/1 + + ... + false + UNACK_RTN + ... + + +``` + +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(@"\\\Galaxy!", …)` per configured + area. The `` and `` 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: `\\\Galaxy!` + (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. diff --git a/lib/Interop.WNWRAPCONSUMERLib.dll b/lib/Interop.WNWRAPCONSUMERLib.dll new file mode 100644 index 0000000..2fccd23 Binary files /dev/null and b/lib/Interop.WNWRAPCONSUMERLib.dll differ diff --git a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs index 75f235f..8e108e4 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/AlarmRecordTransitionMapperTests.cs @@ -1,19 +1,19 @@ +using System; +using MxGateway.Contracts.Proto; using MxGateway.Worker.MxAccess; namespace MxGateway.Worker.Tests.MxAccess; /// -/// PR A.5 — pins the reference-composition logic used to translate AVEVA -/// AlarmRecord events into proto-friendly fields. Transition-kind mapping -/// (a trivial 4-line switch over eAlmTransitions) is verified on -/// the dev rig as part of the live alarm-event smoke test rather than -/// as a unit test, because the AVEVA-licensed enum assembly is -/// Private=false on the reference and is not copied to the test -/// bin directory. +/// Pins the pure helpers used to translate AVEVA's wnwrapConsumer XML +/// payloads into proto-friendly fields. The COM-side I/O on +/// needs an AVEVA install and is +/// covered by the Skip-gated probe (WnWrapConsumerProbeTests); +/// these unit tests cover everything that doesn't touch the live COM +/// surface. /// public sealed class AlarmRecordTransitionMapperTests { - [Fact] public void ComposeFullReference_uses_provider_bang_group_dot_name_format() { @@ -47,4 +47,76 @@ public sealed class AlarmRecordTransitionMapperTests providerName: null, groupName: null, alarmName: "Bare"); Assert.Equal("Bare", reference); } + + [Theory] + [InlineData("UNACK_ALM", MxAlarmStateKind.UnackAlm)] + [InlineData("ACK_ALM", MxAlarmStateKind.AckAlm)] + [InlineData("UNACK_RTN", MxAlarmStateKind.UnackRtn)] + [InlineData("ACK_RTN", MxAlarmStateKind.AckRtn)] + [InlineData("unack_alm", MxAlarmStateKind.UnackAlm)] // case-insensitive + [InlineData(" ACK_ALM ", MxAlarmStateKind.AckAlm)] // trim + [InlineData("UNKNOWN", MxAlarmStateKind.Unspecified)] + [InlineData("", MxAlarmStateKind.Unspecified)] + [InlineData(null, MxAlarmStateKind.Unspecified)] + public void ParseStateKind_decodes_state_strings(string? input, MxAlarmStateKind expected) + { + Assert.Equal(expected, AlarmRecordTransitionMapper.ParseStateKind(input)); + } + + [Theory] + // First sighting: new alarm in *_ALM → Raise. + [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] + [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Raise)] + // First sighting in *_RTN → Clear (unusual; missed the original raise). + [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)] + [InlineData(MxAlarmStateKind.Unspecified, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)] + // Active → Cleared. + [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackRtn, AlarmTransitionKind.Clear)] + [InlineData(MxAlarmStateKind.AckAlm, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Clear)] + // Cleared → Active (re-trigger). + [InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] + [InlineData(MxAlarmStateKind.AckRtn, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Raise)] + // Unacked → Acked (operator ack). + [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.AckAlm, AlarmTransitionKind.Acknowledge)] + [InlineData(MxAlarmStateKind.UnackRtn, MxAlarmStateKind.AckRtn, AlarmTransitionKind.Acknowledge)] + // No-op (state unchanged) — caller is supposed to filter these out. + [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.UnackAlm, AlarmTransitionKind.Unspecified)] + // Current=Unspecified → Unspecified. + [InlineData(MxAlarmStateKind.UnackAlm, MxAlarmStateKind.Unspecified, AlarmTransitionKind.Unspecified)] + public void MapTransition_decides_proto_kind( + MxAlarmStateKind previous, + MxAlarmStateKind current, + AlarmTransitionKind expected) + { + Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current)); + } + + [Fact] + public void ParseTransitionTimestampUtc_assembles_utc_from_xml_fields() + { + // Captured payload from probe (2026-05-01): EDT producer, GMTOFFSET=240, DSTADJUST=0. + // Local 13:26:14.709 + 240 minutes (4h) = 17:26:14.709 UTC. + DateTime utc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc( + "2026/5/1", "13:26:14.709", gmtOffsetMinutes: 240, dstAdjustMinutes: 0); + + Assert.Equal(DateTimeKind.Utc, utc.Kind); + Assert.Equal(2026, utc.Year); + Assert.Equal(5, utc.Month); + Assert.Equal(1, utc.Day); + Assert.Equal(17, utc.Hour); + Assert.Equal(26, utc.Minute); + Assert.Equal(14, utc.Second); + Assert.Equal(709, utc.Millisecond); + } + + [Fact] + public void ParseTransitionTimestampUtc_returns_min_value_on_unparseable_inputs() + { + Assert.Equal(DateTime.MinValue, + AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(null, null, 0, 0)); + Assert.Equal(DateTime.MinValue, + AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("not a date", "13:00:00", 0, 0)); + Assert.Equal(DateTime.MinValue, + AlarmRecordTransitionMapper.ParseTransitionTimestampUtc("2026/5/1", "not a time", 0, 0)); + } } diff --git a/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs b/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs new file mode 100644 index 0000000..3a3d90d --- /dev/null +++ b/src/MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; +using MxGateway.Worker.MxAccess; + +namespace MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit-test coverage for 's pure +/// parsing helpers — XML payload → +/// dictionary, and the 32-char-hex GUID round-trip. The COM-side +/// polling loop is verified separately by the Skip-gated +/// WnWrapConsumerProbeTests on a live AVEVA install. +/// +public sealed class WnWrapAlarmConsumerXmlTests +{ + /// Captured XML from the dev rig (probe run 2026-05-01). + private const string SingleAlarmActiveXml = + "" + + "BCC4705395424D65BDAABCDEA6A32A73" + + "2026/5/1" + + "2400" + + "DESKTOP-6JL3KKO" + + "Galaxy" + + "TestArea" + + "TestMachine_001.TestAlarm001" + + "DSCtruetrue" + + "500UNACK_ALM" + + "" + + "Test alarm #1" + + ""; + + private const string EmptyXml = + ""; + + [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( + "BCC4705395424D65BDAABCDEA6A32A73", + "not-a-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); + } +} diff --git a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj index a3fdd4e..55b9608 100644 --- a/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj +++ b/src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj @@ -41,6 +41,12 @@ true false + + ..\..\lib\Interop.WNWRAPCONSUMERLib.dll + true + false + false + diff --git a/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs b/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs new file mode 100644 index 0000000..3840e7d --- /dev/null +++ b/src/MxGateway.Worker.Tests/WnWrapConsumerProbeTests.cs @@ -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; + +/// +/// 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 GetXmlCurrentAlarms2 while a System Platform script flips +/// TestMachine_001.TestAlarm001 every 10s. The XML payload bypasses +/// the FILETIME→DateTime auto-marshaling that crashes +/// aaAlarmManagedClient.AlarmClient.GetHighPriAlarm. +/// +/// Skip-gated; flip Skip=null to run on the dev rig. +/// +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 = + "" + + "" + + $"{Environment.MachineName}" + + "Galaxy" + + "DEV" + + "" + + ""; + + 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 log = new ConcurrentQueue(); + 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() ?? "", 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() ?? ""; + 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() ?? ""; + 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]"; + } +} diff --git a/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs b/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs deleted file mode 100644 index ae878fa..0000000 --- a/src/MxGateway.Worker/MxAccess/AlarmClientConsumer.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using AlarmMgrDataProviderCOM; -using aaAlarmManagedClient; - -namespace MxGateway.Worker.MxAccess; - -/// -/// PR A.5 — production backed by -/// aaAlarmManagedClient.AlarmClient. Forwards -/// GetAlarmChangesCompleted events into the worker's event queue -/// via . -/// -/// -/// -/// ⚠ Architecture finding (2026-05-01 reflection probe — -/// see docs/AlarmClientDiscovery.md): contrary to the -/// original PR A.5 design, aaAlarmManagedClient.AlarmClient -/// exposes zero public events on the deployed assembly -/// (aaAlarmManagedClient.dll v1.0.7368.41290). There is no -/// managed event surface. RegisterConsumer(hWnd, …) 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 -/// GetStatistics + GetAlarmExtendedRec on each poke. -/// -/// -/// As a result, has no production -/// caller — is invoked only -/// from tests. currently calls -/// RegisterConsumer(hWnd: 0, …) + Subscribe(…) 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 MX_EVENT_FAMILY_ON_ALARM_TRANSITION family -/// cannot carry any events. -/// -/// -/// 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 -/// hWnd: 0 default with a real hidden message-only window -/// hWnd owned by the worker's STA, and add a WindowProc that -/// routes the AVEVA WM_APP message into a change-pull path that -/// ultimately invokes . -/// and -/// are pull-style and don't depend on the event surface — they're -/// correct as is. -/// -/// -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()) - { - } - - /// Test seam — inject a pre-created . - internal AlarmClientConsumer(AlarmClient client) - { - this.client = client ?? throw new ArgumentNullException(nameof(client)); - } - - /// - public event EventHandler? AlarmRecordReceived; - - /// - 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}."); - } - } - } - - /// - 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); - } - - /// - public IReadOnlyList 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[] positions = Array.Empty(); - int[] handles = Array.Empty(); - 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(); - } - - List records = new List(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; - } - - /// - /// 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. - /// - internal void RaiseAlarmRecordReceived(AlarmRecord record) - { - AlarmRecordReceived?.Invoke(this, record); - } - - /// - public void Dispose() - { - if (disposed) return; - disposed = true; - try { client.DeregisterConsumer(); } catch { } - try { client.Dispose(); } catch { } - } -} diff --git a/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs b/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs index 0aa3cba..4865828 100644 --- a/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs +++ b/src/MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs @@ -1,46 +1,77 @@ using System; -using AlarmMgrDataProviderCOM; using MxGateway.Contracts.Proto; namespace MxGateway.Worker.MxAccess; /// -/// PR A.5 — translation helpers between AVEVA's -/// enum and the proto's -/// , plus alarm-reference composition. +/// Translation helpers between the wnwrapConsumer XML payload and the +/// proto-friendly wire format, plus +/// alarm-reference composition. /// /// /// -/// The full → proto-fields decoder lives -/// in . The two pieces that don't -/// need hardware validation (transition-kind enum mapping + -/// provider/group/name → reference string format) live here so the -/// consumer's hot-path stays focused on COM-side field access. +/// These mappings stay pure and library-agnostic so they're unit +/// testable without an AVEVA install. The COM-side I/O lives on +/// . /// /// public static class AlarmRecordTransitionMapper { /// - /// Maps the AVEVA enum onto the proto's - /// . Transitions outside the four - /// primary kinds (raise/ack/clear/retrigger) collapse to - /// so the EventPump's - /// decoding-failure counter records them. + /// Decode AVEVA's STATE string (one of UNACK_ALM, ACK_ALM, + /// UNACK_RTN, ACK_RTN) into the worker's library-agnostic + /// . Unknown values map to + /// . /// - public static AlarmTransitionKind MapTransitionKind(eAlmTransitions native) + public static MxAlarmStateKind ParseStateKind(string? stateXml) { - // ALM = active-raise, RTN = return-to-normal/clear, ACK = acknowledge. - // SUB / ENB / DIS / SUP / REL / REMOVE — substitute / enable / disable / - // suppress / release / remove. None of those map to OPC UA Part 9 - // transitions today; future work could add a Substituted / Suppressed - // proto kind if a customer needs it. - switch (native) + if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified; + return stateXml!.Trim().ToUpperInvariant() switch { - case eAlmTransitions.almRec_trans_ALM: return AlarmTransitionKind.Raise; - case eAlmTransitions.almRec_trans_ACK: return AlarmTransitionKind.Acknowledge; - case eAlmTransitions.almRec_trans_RTN: return AlarmTransitionKind.Clear; - default: return AlarmTransitionKind.Unspecified; + "UNACK_ALM" => MxAlarmStateKind.UnackAlm, + "ACK_ALM" => MxAlarmStateKind.AckAlm, + "UNACK_RTN" => MxAlarmStateKind.UnackRtn, + "ACK_RTN" => MxAlarmStateKind.AckRtn, + _ => MxAlarmStateKind.Unspecified, + }; + } + + /// + /// Decide which proto transition kind a state change represents. + /// The decision table: + /// + /// previous=Unspecified + current=*Alm → Raise (new alarm). + /// previous=Unspecified + current=*Rtn → Clear (alarm appeared in cleared state — rare; missed the raise). + /// previous=Unack* + current=Ack* → Acknowledge. + /// previous=*Alm + current=*Rtn → Clear. + /// previous=*Rtn + current=*Alm → Raise (re-trigger after clear). + /// Anything else → Unspecified (no proto kind to emit). + /// + /// + 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; } /// @@ -63,4 +94,90 @@ public static class AlarmRecordTransitionMapper ? $"{provider}!{name}" : $"{provider}!{group}.{name}"; } + + /// + /// Reassemble a UTC from the wnwrap XML's + /// DATE + TIME + GMTOFFSET + DSTADJUST + /// fields. Returns 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). + /// + /// e.g. "2026/5/1" (no zero-padding). + /// e.g. "13:26:14.709". + /// Offset of the producer's local time vs UTC, in minutes. + /// DST adjustment already applied to local time, in minutes. + 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); + } } diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs b/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs index bb7f116..b7ae357 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs +++ b/src/MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs @@ -1,40 +1,57 @@ using System; -using AlarmMgrDataProviderCOM; +using System.Collections.Generic; namespace MxGateway.Worker.MxAccess; /// -/// PR A.5 — abstraction over aaAlarmManagedClient.AlarmClient's -/// subscribe / event-receive surface. The production implementation -/// () wraps the AVEVA managed client; -/// tests substitute a fake to exercise the wiring against canned -/// events without a live Galaxy. +/// Abstraction over an AVEVA alarm-consumer COM library. The production +/// implementation () wraps +/// WNWRAPCONSUMERLib.wwAlarmConsumerClass from +/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll; +/// tests substitute a fake to drive transition events without a live +/// Galaxy. /// +/// +/// +/// The receive surface is poll-based: the production consumer +/// periodically calls GetXmlCurrentAlarms2, parses the +/// returned XML payload, diffs against the previous snapshot keyed +/// by alarm GUID, and raises +/// once per state change. This bypasses the FILETIME marshaling +/// crash in aaAlarmManagedClient.AlarmClient.GetHighPriAlarm +/// (see docs/AlarmClientDiscovery.md) — XML strings carry +/// timestamps as ASCII fields, no DateTime auto-conversion happens +/// on the .NET interop boundary. +/// +/// public interface IMxAccessAlarmConsumer : IDisposable { /// - /// Fires once per alarm record the AVEVA alarm provider emits. The - /// subscriber is expected to forward each record to a transition mapper - /// and then onto the worker's event queue. Fired on the alarm-client's - /// internal callback thread; subscribers that need STA affinity must - /// marshal back themselves. + /// Fires once per detected alarm-state transition (raise, acknowledge, + /// clear, or new-alarm-already-acked-on-arrival). Subscribers are + /// expected to translate the record into the proto family + /// OnAlarmTransition and enqueue it. Fired on the consumer's + /// polling thread (the worker's STA in production); subscribers that + /// need a different thread must marshal back themselves. /// - event EventHandler? AlarmRecordReceived; + event EventHandler? AlarmTransitionEmitted; /// - /// Initializes the AVEVA alarm-client connection and subscribes to the - /// supplied alarm-provider expression. Subscription string follows - /// AVEVA's syntax (e.g. "\Galaxy!OperationsRoom.AlarmGroup" or - /// "\\GR1\Galaxy!" for a whole Galaxy). + /// Initializes the AVEVA alarm-client connection, registers as a + /// consumer, and subscribes to the supplied alarm-provider expression. + /// Subscription string follows AVEVA's canonical format: + /// \\<node>\Galaxy!<area>. 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. /// void Subscribe(string subscription); /// /// Acknowledges a single alarm with full operator-identity fidelity. - /// Reaches the AVEVA alarm provider's native ack API - /// (AlarmAckByGUID); operator user / node / domain / full-name - /// and the comment land atomically with the ack transition in the - /// alarm-history log. + /// Reaches AVEVA's native AlarmAckByGUID; operator + /// user / node / domain / full-name and the comment land atomically + /// with the ack transition in the alarm-history log. /// int AcknowledgeByGuid( Guid alarmGuid, @@ -45,10 +62,10 @@ public interface IMxAccessAlarmConsumer : IDisposable string ackOperatorFullName); /// - /// Walks the currently-active alarm set and yields each as an - /// . Used by the gateway's QueryActiveAlarms - /// (PR A.7) ConditionRefresh path — operator clients call this after - /// reconnect to seed local Part 9 state. + /// Returns the consumer's most recently parsed snapshot of currently + /// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7) + /// ConditionRefresh path — operator clients call this after reconnect + /// to seed local Part 9 state. /// - System.Collections.Generic.IReadOnlyList SnapshotActiveAlarms(); + IReadOnlyList SnapshotActiveAlarms(); } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs index 450067a..59bf61e 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -4,70 +4,21 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.MxAccess; /// -/// PR A.2 sink for native MxAccess alarm transitions. Bridges the -/// aaAlarmManagedClient.AlarmClient consumer to the worker's -/// event queue, producing messages -/// via . +/// Sink for native MxAccess alarm transitions. Bridges +/// to the worker's event queue, +/// producing messages via +/// . /// /// /// -/// Architecture (revised 2026-05-01 — see -/// docs/AlarmClientDiscovery.md): the worker hosts -/// aaAlarmManagedClient.AlarmClient alongside the existing -/// ArchestrA.MxAccess COM consumer. Both are x86 .NET -/// Framework 4.8. The MxAccess COM Toolkit at -/// C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll -/// exposes no alarm events; the alarm provider lives in a separate -/// AVEVA service that aaAlarmManagedClient subscribes to. -/// -/// -/// Notification mechanism: WM_APP pump. A reflection -/// probe of aaAlarmManagedClient.dll (v1.0.7368.41290) on -/// 2026-05-01 confirmed the public AlarmClient 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 -/// RegisterConsumer(hWnd, …); the consumer pulls the change -/// set via GetStatistics + GetAlarmExtendedRec on each -/// poke. PR A.5's still owns the -/// handle and the -/// / -/// pull-style calls; only -/// the receive path is wrong. -/// -/// -/// Discovered API surface (see -/// AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface in -/// MxGateway.Worker.Tests — Skip-gated reflection probe; full -/// output captured in docs/AlarmClientDiscovery.md): -/// -/// -/// RegisterConsumer(hWnd, productName, applicationName, version, retainHidden) — registers a Windows-message-pump consumer; the AVEVA alarm service WM_APP-pokes the hWnd when alarms change. -/// Subscribe(provider, fromPri, toPri, queryType, sortFlags, filterMask, filterSpec) — subscribes to a Galaxy alarm provider with priority + filter scoping. -/// GetStatistics(out percentQuery, totalAlarms, activeAlarms, …, out int[] changeCodes, out int[] changePos, out int[] hAlarm) — called on each WM_APP poke; enumerates which alarms changed. -/// GetAlarmExtendedRec(index, out AlarmRecord) — pulls the full alarm record (operator, comment, original raise, category, severity). -/// AlarmAckByGUID(alarmGuid, ackComment, oprName, oprNode, oprDomain, oprFullName) — full-fidelity native Acknowledge: comment + four operator-identity fields are atomic with the ack transition. -/// -/// -/// Open questions before A.2 implementation -/// (see docs/AlarmClientDiscovery.md "Implications for A.2"): -/// -/// -/// WM_APP message ID — not in the public surface, needs AVEVA C++ Toolkit reference or a runtime probe. -/// wParam / lParam semantics — likely none (the pattern is "got poked → pull state via GetStatistics"), but confirm during the probe. -/// STA / threading affinity for the message-only window — likely the worker's existing STA, but if AVEVA assumes UI-thread inside GetStatistics the alarm path may need its own STA. -/// Subscription scope — reuse the configured Galaxy name from the data session. -/// -/// -/// Until A.2 lands a hidden message-only window + WindowProc that -/// routes WM_APP into , -/// is a no-op. The worker continues to function -/// for data subscriptions, and the gateway's -/// family is reserved -/// on the wire but never emitted. lmxopcua-side -/// AlarmConditionService keeps the sub-attribute synthesis -/// active and continues to surface alarms to OPC UA Part 9 clients -/// in the meantime. +/// The dispatcher subscribes the consumer's +/// event +/// to at session attach time. The +/// override here is a stub kept for the data- +/// session shape; the actual wire-up between consumer and sink +/// lives in the A.3 dispatcher (one step up the stack). Captured +/// payload schema and consumer threading discipline are described in +/// docs/AlarmClientDiscovery.md "Option A — captured". /// /// public sealed class MxAccessAlarmEventSink : IMxAccessEventSink diff --git a/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs b/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs new file mode 100644 index 0000000..23c1816 --- /dev/null +++ b/src/MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs @@ -0,0 +1,59 @@ +using System; + +namespace MxGateway.Worker.MxAccess; + +/// +/// Library-agnostic alarm-state enum. Mirrors the four STATE +/// values returned by AVEVA's WNWRAPCONSUMERLib XML payload — +/// UNACK_ALM, ACK_ALM, UNACK_RTN, ACK_RTN. +/// Decoupling the consumer from any specific COM library keeps the +/// proto-build path testable without an AVEVA install. +/// +public enum MxAlarmStateKind +{ + Unspecified = 0, + UnackAlm = 1, + AckAlm = 2, + UnackRtn = 3, + AckRtn = 4, +} + +/// +/// Single alarm record as emitted by the wnwrapConsumer XML stream. +/// Field names match the captured XML schema (see +/// docs/AlarmClientDiscovery.md "Option A — captured" section). +/// +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; +} + +/// +/// 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. +/// +public sealed class MxAlarmTransitionEvent : EventArgs +{ + public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord(); + + /// + /// The state on the consumer's previous polled snapshot, or + /// when this is the + /// first time the GUID has been observed. + /// + public MxAlarmStateKind PreviousState { get; set; } +} diff --git a/src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs b/src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs new file mode 100644 index 0000000..04421af --- /dev/null +++ b/src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs @@ -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; + +/// +/// Production backed by AVEVA's +/// standalone WNWRAPCONSUMERLib.wwAlarmConsumerClass COM object +/// (CLSID {7AB52E5F-36B2-4A30-AE46-952A746F667C}, hosted by +/// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll). +/// +/// +/// +/// Replaces the earlier AlarmClientConsumer built on +/// aaAlarmManagedClient.AlarmClient, which crashed in +/// GetHighPriAlarm with ArgumentOutOfRangeException +/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps). +/// The wnwrap surface returns the alarm record as a BSTR XML string +/// via GetXmlCurrentAlarms2; timestamps arrive as ASCII +/// DATE + TIME + GMTOFFSET + DSTADJUST +/// fields and never touch the .NET DateTime marshaler. See +/// docs/AlarmClientDiscovery.md "Option A — captured" for +/// the discovery and the captured payload schema. +/// +/// +/// Threading. The wnwrap CLSID is registered with +/// ThreadingModel=Apartment. The consumer must be created +/// and operated from an STA thread; the worker's +/// already runs an STA pump that +/// is the natural host. Polling cadence is governed by +/// on a dedicated timer the +/// consumer owns; in production the worker's STA dispatcher should +/// marshal each callback onto the STA thread before invoking +/// GetXmlCurrentAlarms2. 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. +/// +/// +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 latestSnapshot = + new Dictionary(); + 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) + { + } + + /// Test seam — inject a pre-created COM client and tune the poll cadence. + 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; + } + + /// + public event EventHandler? AlarmTransitionEmitted; + + public int PollIntervalMilliseconds => pollIntervalMs; + + /// + 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); + } + } + + /// + 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); + } + + /// + public IReadOnlyList SnapshotActiveAlarms() + { + if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); + lock (syncRoot) + { + List active = new List(); + 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 next = ParseSnapshotXml(xml); + + List transitions = new List(); + lock (syncRoot) + { + foreach (KeyValuePair 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 kv in next) + { + latestSnapshot[kv.Key] = kv.Value; + } + } + + if (transitions.Count == 0) return; + EventHandler? handler = AlarmTransitionEmitted; + if (handler is null) return; + foreach (MxAlarmTransitionEvent transition in transitions) + { + handler.Invoke(this, transition); + } + } + + /// + /// Parse the XML payload returned by GetXmlCurrentAlarms2 + /// into a GUID-keyed dictionary. Records with malformed GUIDs are + /// silently dropped (no fault is recorded — the next poll will + /// resync). + /// + public static Dictionary ParseSnapshotXml(string xml) + { + Dictionary records = + new Dictionary(); + + 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; + } + + /// + /// wnwrap's XML GUID field is a 32-char hex string with no + /// dashes (e.g. "BCC4705395424D65BDAABCDEA6A32A73"). Convert + /// to 's canonical 8-4-4-4-12 layout. + /// + 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; + } + + /// + 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 */ } + } + } + } +} diff --git a/src/MxGateway.Worker/MxGateway.Worker.csproj b/src/MxGateway.Worker/MxGateway.Worker.csproj index 8ecee08..6850b7f 100644 --- a/src/MxGateway.Worker/MxGateway.Worker.csproj +++ b/src/MxGateway.Worker/MxGateway.Worker.csproj @@ -24,15 +24,11 @@ false false - - C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll - false - false - - - C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\IAlarmMgrDataProvider.dll - false + + ..\..\lib\Interop.WNWRAPCONSUMERLib.dll + true false + false