From f490ae25937a3c92255668d9fa18efd2559c334d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 1 May 2026 09:15:37 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20revise=20interop=20fix=20path=20?= =?UTF-8?q?=E2=80=94=20wnwrapConsumer.dll=20is=20the=20right=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflection on aaAlarmManagedClient.AlarmClient shows it implements only IDisposable (no [ComImport] interface, no class GUID) and has a single field "CwwAlarmConsumer* m_almUnmanaged". So AlarmClient is a C++/CLI managed wrapper around a native C++ class -- NOT a COM-interop class. The DateTime conversion happens INSIDE AVEVA's wrapper IL, not at the .NET-COM marshaling boundary. There's no separate COM interface to QI to. Revised approach (in docs/AlarmClientDiscovery.md): A. wnwrapConsumer.dll -- separate standalone COM library AVEVA ships at "C:\Program Files (x86)\Common Files\ArchestrA" exposing WNWRAPCONSUMERLib.wwAlarmConsumerClass with SetXmlAlarmQuery / GetXmlCurrentAlarms. XML-string output bypasses FILETIME marshaling entirely. Best fit -- real COM, self-contained, conventional production-grade approach. B. Patch aaAlarmManagedClient.dll IL -- direct but modifies a vendor binary, brittle to upgrades. C. Reflect into m_almUnmanaged and call native vtable directly -- requires reverse-engineering the C++ class layout. Picking A. Probe restored to Skip; next commit starts the wnwrapConsumer integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/AlarmClientDiscovery.md | 30 ++++++++++++ .../AlarmClientWmProbeTests.cs | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md index 0a05b7f..d9fe774 100644 --- a/docs/AlarmClientDiscovery.md +++ b/docs/AlarmClientDiscovery.md @@ -392,6 +392,36 @@ disruptive. Once the interop is custom, `AlarmClient.Subscribe` + `GetHighPriAlarm` + `GetAlarmExtendedRec` form a viable polling-style alarm consumer. +**REVISED 2026-05-01 — option 1 not directly applicable.** +Reflection on `aaAlarmManagedClient.AlarmClient` shows it +implements only `IDisposable` (no `[ComImport]` interface, no +class GUID). It has a single field `CwwAlarmConsumer* +m_almUnmanaged` — meaning `AlarmClient` is a **C++/CLI managed +wrapper around a native C++ class**, NOT a COM-interop class. +The DateTime conversion happens inside the AVEVA wrapper's IL, +not at a .NET-to-COM marshaling boundary. There is no separate +COM interface IID we can QI to. + +Revised approach options: + +A. **Switch to `wnwrapConsumer.dll`** — a separate standalone + COM library AVEVA ships at + `C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll` + exposing `WNWRAPCONSUMERLib.wwAlarmConsumerClass` with + `SetXmlAlarmQuery` / `GetXmlCurrentAlarms`. XML-string output + bypasses FILETIME marshaling entirely. +B. **Patch `aaAlarmManagedClient.dll` IL** — wrap the unsafe + `DateTime.FromFileTime` calls with a safe variant. Direct + fix but modifies a vendor binary. +C. **Reflect into `m_almUnmanaged` and call native vtable** — + get the IntPtr, walk the MSVC C++ vtable, call + `__thiscall` methods via `Marshal.GetDelegateForFunctionPointer`. + Doable but requires reverse-engineering the C++ class layout. + +Option A is the best fit: real COM-based, self-contained in +our code, conventional production-grade approach (the WIN-911 +consumer pattern referenced in AVEVA support forums uses it). + The polling-vs-WM_APP-callback question from earlier is now moot: `GetStatistics`'s `positions[]/handles[]` arrays remained empty even when alarms were demonstrably present. The active diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs index 6e156c4..ad716de 100644 --- a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs +++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs @@ -250,6 +250,52 @@ public sealed class AlarmClientWmProbeTests : IDisposable { client = new AlarmClient(); + // One-time interop introspection: dump AlarmClient's class GUID + // (CoClass IID) and every interface it implements with their + // GUID + InterfaceType. The IID we need to redeclare with safe + // blittable types is the one whose vtable carries + // GetHighPriAlarm. + try + { + Type ct = client.GetType(); + Log($"=== AlarmClient interop introspection ==="); + Log($"Class FullName: {ct.FullName}"); + var classGuid = ct.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true) + .Cast().FirstOrDefault(); + Log($"Class GUID: {classGuid?.Value ?? "(none)"}"); + foreach (var iface in ct.GetInterfaces()) + { + var ig = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.GuidAttribute), true) + .Cast().FirstOrDefault(); + var ity = iface.GetCustomAttributes(typeof(System.Runtime.InteropServices.InterfaceTypeAttribute), true) + .Cast().FirstOrDefault(); + int methodCount = iface.GetMethods().Length; + Log($" iface {iface.FullName} | GUID={ig?.Value ?? "(none)"} | type={ity?.Value.ToString() ?? "(none)"} | methods={methodCount}"); + } + // Dump fields (private/internal) — the COM object reference + // is likely on a private field. + Log($"--- AlarmClient instance fields ---"); + foreach (var f in ct.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + Log($" field {f.FieldType.FullName} {f.Name} (public={f.IsPublic})"); + } + // Dump base class chain. + Log($"--- base class chain ---"); + Type? baseT = ct.BaseType; + int depth = 0; + while (baseT != null && depth < 5) + { + Log($" base[{depth}]: {baseT.FullName}"); + baseT = baseT.BaseType; + depth++; + } + Log($"=== end introspection ==="); + } + catch (Exception ex) + { + Log($"Interop introspection threw: {ex.GetType().Name}: {ex.Message}"); + } + // Try InitializeConsumer first — separate from RegisterConsumer // per the discovered API surface; previous probe runs skipped // it. Some AVEVA managed-client patterns require Initialize