6affbd57ea
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m19s
ci / java (push) Successful in 2m8s
ci / portable (push) Failing after 2m23s
ci / nightly-windev (pull_request) Has been skipped
ci / windows-x86 (pull_request) Successful in 1m18s
ci / java (pull_request) Successful in 2m3s
ci / portable (pull_request) Failing after 2m17s
Adds a managed acknowledge path over AVEVA's modern alarm client so GUID acks carry full operator identity (domain + full name) and work on builds where the wnwrap COM AlarmAckByGUID is E_NOTIMPL. Default off; opt in with MXGATEWAY_USE_MANAGED_ALARM_ACK=1 (wnwrap behavior unchanged otherwise). - AaManagedAlarmAckConsumer: reflection wrapper over aaAlarmManagedClient.AlarmClient (InitializeConsumer/RegisterConsumer/ Subscribe/AlarmAckByGUID); EnvGatedFactory degrades to null (wnwrap) if the DLL can't load, so alarm reads never break. - IManagedAlarmAckClient seam. AlarmDispatcher takes an optional client and routes GUID acks to it (subscribing + disposing it alongside the reader). - AlarmCommandHandler gains an optional managedAckFactory; WorkerPipeSession wires AaManagedAlarmAckConsumer.EnvGatedFactory at the production sites. - WnalNativeAckConsumer: confirmed native alarmlst.dll WNAL submit layer (WNALalarmReqSetText / WNALUserRequest / WNALUserRequestEx + lpAlmAck layout). List acquisition is intentionally not hand-rolled: the alarm-record layout and selection semantics are provider-defined and not recoverable from alarmlst.dll, so inferring them risks acking the wrong alarm. - AlarmManagedGuidAckLiveSmokeTests: skip-gated live smoke that pulls a real (non-degraded) alarm GUID and asserts the ack APPLIES (Acknowledge transition / acked snapshot), not merely rc=0. By-name acks stay on wnwrap (AlarmClient has no AlarmAckByName). Grounded in static RE of alarmlst.dll, aaAlarmManagedClient.dll and alarmmgr.exe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
11 KiB
C#
232 lines
11 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using WNWRAPCONSUMERLib;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Acknowledge path over AVEVA's modern managed alarm client,
|
|
/// <c>aaAlarmManagedClient.AlarmClient</c> (the C++/CLI assembly the AVEVA
|
|
/// Alarm Control itself uses: <c>AlarmClient → CwwAlarmConsumer →
|
|
/// alarmlst.dll → alarmmgr.exe</c>). Preferred over a hand-rolled native
|
|
/// WNAL consumer because <c>AlarmAckByGUID</c> performs the
|
|
/// register → match-by-GUID → submit sequence internally — the record
|
|
/// layout and selection semantics that are provider-defined (and therefore
|
|
/// NOT safely recoverable by static RE of alarmlst.dll) stay inside AVEVA's
|
|
/// own code. See <see cref="WnalNativeAckConsumer"/> for why the native
|
|
/// acquisition path is deliberately unfinished.
|
|
///
|
|
/// The <c>AlarmClient</c> members are <c>famorasm</c> (protected-internal)
|
|
/// and mxaccessgw is not an <c>InternalsVisibleTo</c> friend, so everything
|
|
/// here is invoked by reflection. In full-trust .NET Framework 4.8 that is
|
|
/// permitted. Method shapes are confirmed from the assembly metadata:
|
|
/// .ctor() (internal)
|
|
/// int InitializeConsumer(string appName)
|
|
/// int RegisterConsumer(int hWnd, string product, string app, string version, bool)
|
|
/// int Subscribe(string expr, short fromPri, short toPri,
|
|
/// localQueryType, localSortFlags,
|
|
/// localAlarmFilterState mask, localAlarmFilterState spec)
|
|
/// int AlarmAckByGUID(System.Guid guid, string comment, string oper,
|
|
/// string node, string domain, string fullName)
|
|
/// int DeregisterConsumer() / int UninitializeConsumer() / Dispose()
|
|
///
|
|
/// THREADING: the underlying consumer is apartment-affine (same as the
|
|
/// wnwrap COM path). Construct and call this only on the worker STA
|
|
/// (marshal via <c>StaRuntime.InvokeAsync</c>).
|
|
///
|
|
/// x86 ONLY — aaAlarmManagedClient.dll is a 32-bit mixed-mode PE.
|
|
///
|
|
/// OPEN (confirm at runtime, flagged inline): the trailing <c>bool</c> of
|
|
/// <c>RegisterConsumer</c> (passed <c>false</c> here); whether
|
|
/// <c>Subscribe</c> is actually required before an ack by GUID; and the
|
|
/// native status returned on a real secured ack.
|
|
/// </summary>
|
|
public sealed class AaManagedAlarmAckConsumer : IManagedAlarmAckClient
|
|
{
|
|
/// <summary>Default install location of the managed alarm client (eac.dll folder).</summary>
|
|
public const string DefaultAssemblyPath =
|
|
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\Invensys_Systems__Inc__eac.dll\aaAlarmManagedClient.dll";
|
|
|
|
/// <summary>
|
|
/// Env var that opts the worker into the managed ack path. Unset/anything
|
|
/// other than "1" keeps the existing wnwrap ack behavior.
|
|
/// </summary>
|
|
public const string EnableEnvVar = "MXGATEWAY_USE_MANAGED_ALARM_ACK";
|
|
|
|
private const string AckApplicationName = "ZB.MOM.WW.MxGateway.ack";
|
|
|
|
/// <summary>
|
|
/// Production factory for the <see cref="AlarmCommandHandler"/> seam:
|
|
/// returns a managed ack client only when <see cref="EnableEnvVar"/> == "1",
|
|
/// otherwise <see langword="null"/> (ack stays on the wnwrap path). Never
|
|
/// throws — a construction failure (missing DLL, etc.) degrades to the
|
|
/// wnwrap path rather than breaking the alarm subscription.
|
|
/// </summary>
|
|
public static IManagedAlarmAckClient? EnvGatedFactory()
|
|
{
|
|
if (!string.Equals(Environment.GetEnvironmentVariable(EnableEnvVar), "1", StringComparison.Ordinal))
|
|
return null;
|
|
try
|
|
{
|
|
return new AaManagedAlarmAckConsumer(AckApplicationName);
|
|
}
|
|
catch
|
|
{
|
|
// Opt-in enhancement only: if the managed client can't load, fall
|
|
// back to the wnwrap ack path rather than failing alarm reads.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private const string TypeName = "aaAlarmManagedClient.AlarmClient";
|
|
private const BindingFlags Inst = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
|
|
|
private readonly object syncRoot = new();
|
|
private readonly object client;
|
|
private readonly MethodInfo initializeConsumer;
|
|
private readonly MethodInfo registerConsumer;
|
|
private readonly MethodInfo subscribe;
|
|
private readonly MethodInfo alarmAckByGuid;
|
|
private readonly MethodInfo? deregisterConsumer;
|
|
private readonly MethodInfo? uninitializeConsumer;
|
|
|
|
private bool subscribed;
|
|
private bool disposed;
|
|
|
|
/// <param name="applicationName">Consumer application name (e.g. "MxGateway.ack").</param>
|
|
/// <param name="assemblyPath">Override for <see cref="DefaultAssemblyPath"/>.</param>
|
|
public AaManagedAlarmAckConsumer(string applicationName, string? assemblyPath = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(applicationName))
|
|
throw new ArgumentException("Application name is required.", nameof(applicationName));
|
|
|
|
string path = assemblyPath ?? DefaultAssemblyPath;
|
|
if (!File.Exists(path))
|
|
throw new FileNotFoundException($"aaAlarmManagedClient.dll not found at '{path}'.", path);
|
|
|
|
Assembly asm = Assembly.LoadFrom(path);
|
|
Type type = asm.GetType(TypeName, throwOnError: true)
|
|
?? throw new InvalidOperationException($"Type '{TypeName}' not found in {path}.");
|
|
|
|
initializeConsumer = Resolve(type, "InitializeConsumer");
|
|
registerConsumer = Resolve(type, "RegisterConsumer");
|
|
subscribe = Resolve(type, "Subscribe");
|
|
alarmAckByGuid = Resolve(type, "AlarmAckByGUID");
|
|
deregisterConsumer = type.GetMethod("DeregisterConsumer", Inst, null, Type.EmptyTypes, null);
|
|
uninitializeConsumer = type.GetMethod("UninitializeConsumer", Inst, null, Type.EmptyTypes, null);
|
|
|
|
client = Activator.CreateInstance(type, nonPublic: true)
|
|
?? throw new InvalidOperationException($"Could not construct '{TypeName}'.");
|
|
|
|
ApplicationName = applicationName;
|
|
}
|
|
|
|
/// <summary>The consumer application name this instance registered under.</summary>
|
|
public string ApplicationName { get; }
|
|
|
|
private static MethodInfo Resolve(Type type, string name) =>
|
|
type.GetMethod(name, Inst)
|
|
?? throw new MissingMethodException(TypeName, name);
|
|
|
|
/// <summary>
|
|
/// Run the Initialize → Register → Subscribe lifecycle once, mirroring the
|
|
/// proven wnwrap argument values. <paramref name="subscription"/> is the
|
|
/// alarm query root, e.g. <c>\\NODE\Galaxy!Area</c>. Throws if any native
|
|
/// status is non-zero.
|
|
/// </summary>
|
|
public void Subscribe(string subscription)
|
|
{
|
|
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
|
|
if (subscribed) throw new InvalidOperationException("Subscribe was already called.");
|
|
|
|
int init = (int)initializeConsumer.Invoke(client, new object[] { ApplicationName })!;
|
|
if (init != 0) throw new InvalidOperationException($"InitializeConsumer returned {init}.");
|
|
|
|
// RegisterConsumer(hWnd, product, app, version, bool). hWnd=0 => pull model.
|
|
// The trailing bool is unconfirmed; false = client/non-modal is the safe default.
|
|
int reg = (int)registerConsumer.Invoke(client, new object[]
|
|
{
|
|
0, "ZB.MOM.WW.MxGateway", ApplicationName, "1.0", false,
|
|
})!;
|
|
if (reg != 0) throw new InvalidOperationException($"RegisterConsumer returned {reg}.");
|
|
|
|
int sub = (int)subscribe.Invoke(client, BuildSubscribeArgs(subscription))!;
|
|
if (sub != 0) throw new InvalidOperationException($"Subscribe('{subscription}') returned {sub}.");
|
|
|
|
subscribed = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acknowledge a single alarm by GUID with full operator identity. Returns
|
|
/// AVEVA's native status (0 = success). A zero return means accepted, not
|
|
/// necessarily applied — verify the alarm's acked state / Historian row
|
|
/// (see docs/AlarmProbeFindings.md).
|
|
/// </summary>
|
|
public int AcknowledgeByGuid(
|
|
Guid alarmGuid,
|
|
string comment,
|
|
string operatorName,
|
|
string operatorNode,
|
|
string operatorDomain,
|
|
string operatorFullName)
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AaManagedAlarmAckConsumer));
|
|
if (!subscribed) throw new InvalidOperationException("Subscribe must be called before acknowledging.");
|
|
|
|
return (int)alarmAckByGuid.Invoke(client, new object[]
|
|
{
|
|
alarmGuid,
|
|
comment ?? string.Empty,
|
|
operatorName ?? string.Empty,
|
|
operatorNode ?? string.Empty,
|
|
operatorDomain ?? string.Empty,
|
|
operatorFullName ?? string.Empty,
|
|
})!;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the 7 Subscribe args, boxing the proven wnwrap enum integers into
|
|
/// aaAlarmManagedClient's own enum parameter types (localQueryType /
|
|
/// localSortFlags / localAlarmFilterState) via <see cref="Enum.ToObject(Type,object)"/>.
|
|
/// </summary>
|
|
private object[] BuildSubscribeArgs(string subscription)
|
|
{
|
|
ParameterInfo[] p = subscribe.GetParameters();
|
|
if (p.Length != 7)
|
|
throw new InvalidOperationException($"Unexpected AlarmClient.Subscribe arity {p.Length}; expected 7.");
|
|
|
|
return new object[]
|
|
{
|
|
subscription,
|
|
(short)1, // wFromPri
|
|
(short)999, // wToPri
|
|
Enum.ToObject(p[3].ParameterType, (int)eQueryType.qtSummary),
|
|
Enum.ToObject(p[4].ParameterType, (int)eSortFlags.sfReturnNewestFirst),
|
|
Enum.ToObject(p[5].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
|
|
Enum.ToObject(p[6].ParameterType, (int)eAlarmFilterState.asAlarmActiveNow),
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
|
|
try { if (subscribed) deregisterConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
|
|
try { uninitializeConsumer?.Invoke(client, Array.Empty<object>()); } catch { /* best effort */ }
|
|
(client as IDisposable)?.Dispose();
|
|
}
|
|
}
|
|
}
|