worker: aaAlarmManagedClient discovery + reference (alarm-helper foundation) #115

Merged
dohertj2 merged 1 commits from track-alarm-helper-discovery into main 2026-04-30 22:20:09 -04:00
3 changed files with 99 additions and 66 deletions
@@ -0,0 +1,59 @@
using System;
using System.Linq;
using System.Reflection;
using Xunit;
using Xunit.Abstractions;
namespace MxGateway.Worker.Tests;
/// <summary>
/// One-shot reflection probe — discovers the public surface of
/// <c>aaAlarmManagedClient.dll</c> so we can design the alarm-helper
/// wiring in the worker. Marked Skip so it doesn't run as part of the
/// normal suite; flip the Skip parameter to see the output.
/// </summary>
public sealed class AlarmClientDiscoveryTests
{
private readonly ITestOutputHelper output;
public AlarmClientDiscoveryTests(ITestOutputHelper output)
{
this.output = output;
}
[Fact(Skip = "Discovery probe — flip Skip=null to dump aaAlarmManagedClient surface")]
public void DumpAlarmClientPublicSurface()
{
Assembly asm = Assembly.LoadFrom(@"C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll");
output.WriteLine($"Assembly: {asm.FullName}");
output.WriteLine("Public types:");
Type[] types = asm.GetExportedTypes()
.OrderBy(t => t.FullName, StringComparer.Ordinal)
.ToArray();
foreach (Type t in types)
{
output.WriteLine($" {t.FullName} ({(t.IsClass ? "class" : t.IsInterface ? "interface" : t.IsEnum ? "enum" : "other")})");
}
output.WriteLine("");
output.WriteLine("Public events / methods on alarm-named types:");
foreach (Type t in types.Where(x => x.Name.IndexOf("Alarm", StringComparison.OrdinalIgnoreCase) >= 0
|| x.Name.IndexOf("Subscription", StringComparison.OrdinalIgnoreCase) >= 0
|| x.Name.IndexOf("Event", StringComparison.OrdinalIgnoreCase) >= 0))
{
output.WriteLine($" {t.FullName}");
foreach (EventInfo e in t.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
{
output.WriteLine($" event {e.EventHandlerType?.Name} {e.Name}");
}
foreach (MethodInfo m in t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (m.IsSpecialName) continue;
string parms = string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.Name}"));
output.WriteLine($" method {m.ReturnType.Name} {m.Name}({parms})");
}
}
}
}
@@ -4,80 +4,49 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// PR A.2 sink intended to register against an MXAccess alarm event source
/// and forward each alarm transition into the worker's event queue as an
/// <see cref="OnAlarmTransitionEvent"/>. The mapper bridge is fully
/// implemented + unit-tested via
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>; the
/// <see cref="Attach"/> path is intentionally a no-op pending the
/// architectural decision documented below.
/// PR A.2 sink for native MxAccess alarm transitions. Bridges the
/// <c>aaAlarmManagedClient.AlarmClient</c> consumer to the worker's
/// event queue, producing <see cref="OnAlarmTransitionEvent"/> messages
/// via <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// </summary>
/// <remarks>
/// <para>
/// <strong>2026-04-30 dev-rig finding:</strong> the MXAccess COM
/// Toolkit installed at <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
/// does <strong>not</strong> expose any alarm event family. Reflection
/// enumeration of the assembly (which exports a single COM interop
/// module containing <c>ILMXProxyServerEvents</c> and
/// <c>ILMXProxyServerEvents2</c>) confirms the only available events
/// are <c>OnDataChange</c>, <c>OnWriteComplete</c>,
/// <c>OperationComplete</c>, and <c>OnBufferedDataChange</c>. There is
/// no <c>OnAlarmTransition</c>, no <c>IAlarmEventSink</c>, and no
/// <c>Alarms</c> collection on the COM server.
/// <strong>Architecture (pinned 2026-04-30):</strong> the worker hosts
/// <c>aaAlarmManagedClient.AlarmClient</c> alongside the existing
/// <c>ArchestrA.MxAccess</c> COM consumer. Both are x86 .NET Framework
/// 4.8 — the worker's existing runtime — and both use the same Windows
/// STA + WM_APP message pump. The MxAccess COM Toolkit at
/// <c>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</c>
/// exposes no alarm events; the alarm provider lives in a separate
/// AVEVA service that <c>aaAlarmManagedClient</c> subscribes to.
/// </para>
/// <para>
/// AVEVA's separate alarm-subscription managed assemblies
/// (<c>aaAlarmManagedClient.dll</c> under
/// <c>InTouch\ViewAppFramework\Content\MA\</c>,
/// <c>ArchestrAAlarmsAndEvents.SDK.Common.dll</c> under
/// <c>Wonderware\Historian\x64\</c>) are present on this box but are
/// <strong>x64-only</strong>; they cannot load into the worker process,
/// which is x86 because of the MXAccess COM bitness constraint that
/// the <c>mxaccessgw</c> architecture exists to isolate. Loading them
/// in a separate x64 helper process would add meaningful operational
/// complexity (a third process tier alongside worker + gateway) and is
/// not in the current architecture.
/// <strong>Discovered API surface</strong> (see
/// <c>AlarmClientDiscoveryTests.DumpAlarmClientPublicSurface</c> in
/// <c>MxGateway.Worker.Tests</c> — Skip-gated reflection probe):
/// </para>
/// <para>
/// <strong>Two paths forward — operator decision needed before the
/// sink can be wired:</strong>
/// </para>
/// <list type="number">
/// <item>
/// <description>
/// <strong>Stay on the value-driven sub-attribute path</strong>
/// (current production behaviour). The lmxopcua server's
/// <c>AlarmConditionService</c> already synthesizes Part 9
/// transitions from the four MXAccess sub-attributes
/// (<c>InAlarm</c>, <c>Acked</c>, <c>Priority</c>,
/// <c>Description</c>) via the data-change subscription.
/// Operator-comment fidelity is the only regression vs. v1; if
/// acceptable, this row stays the production path and the
/// <see cref="MxEventFamily.OnAlarmTransition"/> family stays
/// reserved-but-dormant on the wire.
/// </description>
/// </item>
/// <item>
/// <description>
/// <strong>Add an x64 alarm-helper sub-process</strong> alongside
/// the worker that loads <c>aaAlarmManagedClient</c>,
/// subscribes to alarms, and forwards transitions to the worker
/// over a small named-pipe IPC. Then this sink's
/// <see cref="Attach"/> connects to that helper instead of to
/// the COM server, and routes each transition through
/// <see cref="EnqueueTransition"/>. Adds operational complexity
/// but recovers full v1 fidelity (operator user, comment,
/// original raise time, category).
/// </description>
/// </item>
/// <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>
/// Until that decision is made, this sink's <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.
/// <strong>Wiring plan (subsequent PRs):</strong>
/// </para>
/// <list type="number">
/// <item><description>Worker session-startup wires <c>AlarmClient.RegisterConsumer</c> against the worker's existing STA hWnd; <c>Subscribe</c> with the Galaxy provider name + a permissive priority/filter range.</description></item>
/// <item><description>The STA's WM_APP handler routes alarm-changed messages into <see cref="EnqueueTransition"/>; the message ID is established at runtime via the consumer's reported handler (verify on dev rig).</description></item>
/// <item><description>Gateway-side <c>AcknowledgeAlarm</c> RPC translates to a worker command that calls <c>AlarmClient.AlarmAckByGUID</c> with the OPC UA operator's resolved identity — replaces the worker-pending diagnostic from PR A.3.</description></item>
/// </list>
/// <para>
/// Until those PRs land, <see cref="Attach"/> is a no-op. The worker
/// continues to function for data subscriptions, and the gateway's
/// <see cref="MxEventFamily.OnAlarmTransition"/> family is reserved on
/// the wire but never emitted. lmxopcua-side <c>AlarmConditionService</c>
/// keeps the sub-attribute synthesis active and continues to surface
/// alarms to OPC UA Part 9 clients in the meantime.
/// </para>
/// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
@@ -24,6 +24,11 @@
<Private>false</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
<Reference Include="aaAlarmManagedClient">
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ViewAppFramework\Content\MA\aaAlarmManagedClient.dll</HintPath>
<Private>false</Private>
<SpecificVersion>false</SpecificVersion>
</Reference>
</ItemGroup>
</Project>