feat(worker): route alarm acks through the operator-authenticated AckMsg writer
AlarmCommandHandler -> AlarmDispatcher -> IAlarmAckWriter: the dispatcher resolves the ack item through the consumer and the writer authenticates the operator and writes the comment. The executor builds AlarmAckOperator from operator_user/domain/password, maps AlarmAckOutcome to the reply (Applied/Unconfirmed Ok, AlarmNotFound/NotAckable InvalidRequest, AuthenticationFailed/WriteRejected MxaccessFailure) and reports escaping exceptions by type and HRESULT only, so operator_password cannot leak. Removes the inert managed/WNAL alarm-client ack path. Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -76,7 +76,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
() => Process.GetCurrentProcess().Id,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
|
||||
logger,
|
||||
stream)
|
||||
{
|
||||
@@ -98,7 +98,7 @@ public sealed class WorkerPipeSession
|
||||
options,
|
||||
processIdProvider,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory)),
|
||||
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
|
||||
logger: null)
|
||||
{
|
||||
}
|
||||
@@ -1585,7 +1585,7 @@ public sealed class WorkerPipeSession
|
||||
// parameterless CompleteStartupHandshakeAsync is used without a
|
||||
// prior factory call.
|
||||
_runtimeSession ??= new MxAccessStaSession(
|
||||
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory));
|
||||
(eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null));
|
||||
IWorkerRuntimeSession session = _runtimeSession;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
@@ -39,10 +40,12 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
private readonly Action? threadAffinityCheck;
|
||||
private readonly IMxAccessComObjectFactory? comFactory;
|
||||
private readonly Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory;
|
||||
private readonly Func<IManagedAlarmAckClient?>? managedAckFactory;
|
||||
private readonly Func<IAlarmAckWriter>? ackWriterFactory;
|
||||
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly IAlarmAckWriter deferredAckWriter;
|
||||
private AlarmDispatcher? dispatcher;
|
||||
private IAlarmAckWriter? ackWriter;
|
||||
private FailoverAlarmConsumer? failoverConsumer;
|
||||
private EventHandler<AlarmProviderModeChange>? providerModeChangedHandler;
|
||||
private string subscribeSessionId = string.Empty;
|
||||
@@ -118,14 +121,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
/// <paramref name="comFactory"/>. Tests inject a fake so they need no
|
||||
/// live COM factory.
|
||||
/// </param>
|
||||
/// <param name="managedAckFactory">
|
||||
/// Optional per-subscription factory for a managed ack client
|
||||
/// (<see cref="IManagedAlarmAckClient"/>). When it yields a non-null
|
||||
/// client, GUID-keyed acknowledges route through AVEVA's modern
|
||||
/// <c>AlarmClient.AlarmAckByGUID</c> instead of the wnwrap COM path.
|
||||
/// Defaults to <see langword="null"/> (wnwrap ack). Production supplies
|
||||
/// <see cref="AaManagedAlarmAckConsumer.EnvGatedFactory"/>, which is a
|
||||
/// no-op unless <c>MXGATEWAY_USE_MANAGED_ALARM_ACK=1</c>.
|
||||
/// <param name="ackWriterFactory">
|
||||
/// Optional seam that builds the <see cref="IAlarmAckWriter"/> used for
|
||||
/// every acknowledgement. Defaults to an <see cref="AlarmAckWriter"/> over
|
||||
/// <paramref name="comFactory"/> that registers its own MXAccess server
|
||||
/// (client name from <see cref="AlarmAckWriter.ResolveClientName"/>).
|
||||
/// The writer is created once per handler, on the first acknowledgement
|
||||
/// (so a subscription never fails because ack is unavailable), survives
|
||||
/// unsubscribe/resubscribe, and is disposed with the handler. Tests
|
||||
/// inject a fake.
|
||||
/// </param>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
@@ -133,14 +137,15 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
Action? threadAffinityCheck,
|
||||
IMxAccessComObjectFactory? comFactory,
|
||||
Func<IReadOnlyList<AlarmSubtagTarget>, IMxAccessAlarmConsumer>? standbyFactory,
|
||||
Func<IManagedAlarmAckClient?>? managedAckFactory = null)
|
||||
Func<IAlarmAckWriter>? ackWriterFactory = null)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
||||
this.threadAffinityCheck = threadAffinityCheck;
|
||||
this.comFactory = comFactory;
|
||||
this.standbyFactory = standbyFactory;
|
||||
this.managedAckFactory = managedAckFactory;
|
||||
this.ackWriterFactory = ackWriterFactory;
|
||||
this.deferredAckWriter = new DeferredAckWriter(this);
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the handler is subscribed.</summary>
|
||||
@@ -181,14 +186,8 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
failover.ProviderModeChanged += providerModeChangedHandler;
|
||||
}
|
||||
|
||||
// Optional managed ack client (opt-in via env); null → wnwrap ack.
|
||||
// A forced-subtag session never has alarmmgr GUIDs to ack, so it
|
||||
// skips the extra managed consumer entirely.
|
||||
IManagedAlarmAckClient? managedAck = command.ForcedMode == AlarmProviderMode.Subtag
|
||||
? null
|
||||
: managedAckFactory?.Invoke();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
|
||||
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, managedAck);
|
||||
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
|
||||
try
|
||||
{
|
||||
dispatcher.Subscribe(subscriptionExpression);
|
||||
@@ -323,47 +322,31 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(
|
||||
alarmGuid,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(alarmGuid, comment ?? string.Empty, ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
public AlarmAckResult AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
AlarmDispatcher d = GetDispatcherOrThrow();
|
||||
return d.AcknowledgeByName(
|
||||
alarmName ?? string.Empty,
|
||||
providerName ?? string.Empty,
|
||||
groupName ?? string.Empty,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
ackOperator);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -398,6 +381,60 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
d?.PollOnce();
|
||||
}
|
||||
|
||||
private IAlarmAckWriter GetOrCreateAckWriter()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
if (ackWriter is not null) return ackWriter;
|
||||
if (ackWriterFactory is not null)
|
||||
{
|
||||
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
|
||||
return ackWriter;
|
||||
}
|
||||
|
||||
if (comFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
|
||||
}
|
||||
|
||||
// StaMessagePump.PumpPendingMessages is PeekMessage/DispatchMessage on
|
||||
// the calling thread — the STA, since acks run there — which is what
|
||||
// delivers the writer's OnWriteComplete callback.
|
||||
ackWriter = new AlarmAckWriter(
|
||||
comFactory,
|
||||
() => new StaMessagePump().PumpPendingMessages(),
|
||||
MxAccessStaSession.ResolveWriteCompletionTimeout(),
|
||||
AlarmAckWriter.ResolveClientName());
|
||||
return ackWriter;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The writer handed to each <see cref="AlarmDispatcher"/>. Resolves the
|
||||
/// handler's real writer on first acknowledgement so the writer (and its
|
||||
/// MXAccess server registration) is created only when an ack happens and
|
||||
/// outlives dispatcher churn. Disposing it is a no-op; the handler owns
|
||||
/// the real writer.
|
||||
/// </summary>
|
||||
private sealed class DeferredAckWriter : IAlarmAckWriter
|
||||
{
|
||||
private readonly AlarmCommandHandler owner;
|
||||
|
||||
public DeferredAckWriter(AlarmCommandHandler owner)
|
||||
{
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator) =>
|
||||
owner.GetOrCreateAckWriter().Acknowledge(itemAddress, comment, ackOperator);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private AlarmDispatcher GetDispatcherOrThrow()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
@@ -418,5 +455,13 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
Unsubscribe();
|
||||
IAlarmAckWriter? writerToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
writerToDispose = ackWriter;
|
||||
ackWriter = null;
|
||||
}
|
||||
|
||||
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
|
||||
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
|
||||
/// through <see cref="AlarmCommandHandler"/>, which owns one
|
||||
/// dispatcher per session.
|
||||
/// dispatcher per session. Acknowledgement resolves the alarm to its
|
||||
/// MXAccess ack item through the consumer and hands the write to the
|
||||
/// <see cref="IAlarmAckWriter"/>, which authenticates as the operator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
|
||||
@@ -36,30 +38,29 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
private readonly MxAccessAlarmEventSink sink;
|
||||
private readonly string sessionId;
|
||||
private readonly EventHandler<MxAlarmTransitionEvent> handler;
|
||||
private IManagedAlarmAckClient? managedAck;
|
||||
private readonly IAlarmAckWriter ackWriter;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, and session ID.</summary>
|
||||
/// <summary>Initializes a new alarm dispatcher for the given consumer, sink, session ID and ack writer.</summary>
|
||||
/// <param name="consumer">The alarm consumer.</param>
|
||||
/// <param name="sink">The alarm event sink.</param>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="managedAck">
|
||||
/// Optional managed ack client. When non-null, GUID-keyed acknowledges
|
||||
/// route to it (AVEVA's modern <c>AlarmClient.AlarmAckByGUID</c>) instead
|
||||
/// of the wnwrap consumer while the alarmmgr provider is active; null
|
||||
/// keeps the existing wnwrap ack path. Subtag-mode acks always stay on
|
||||
/// the consumer because their GUIDs are synthetic.
|
||||
/// <param name="ackWriter">
|
||||
/// Writes the resolved ack item as the authenticated operator. Owned by
|
||||
/// the caller (<see cref="AlarmCommandHandler"/>), which disposes it; the
|
||||
/// dispatcher never does, so the writer's registered MXAccess server
|
||||
/// outlives an unsubscribe/resubscribe cycle.
|
||||
/// </param>
|
||||
public AlarmDispatcher(
|
||||
IMxAccessAlarmConsumer consumer,
|
||||
MxAccessAlarmEventSink sink,
|
||||
string sessionId,
|
||||
IManagedAlarmAckClient? managedAck = null)
|
||||
IAlarmAckWriter ackWriter)
|
||||
{
|
||||
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
this.sessionId = sessionId ?? string.Empty;
|
||||
this.managedAck = managedAck;
|
||||
this.ackWriter = ackWriter ?? throw new ArgumentNullException(nameof(ackWriter));
|
||||
// Sink.Attach is the seam that propagates the session id onto the
|
||||
// proto SessionId field of every emitted MxEvent. Pass the consumer
|
||||
// as the "associated COM object" — sink ignores the object reference
|
||||
@@ -80,113 +81,37 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
consumer.Subscribe(subscription);
|
||||
// The managed ack client maintains its own registered/subscribed
|
||||
// consumer; run its lifecycle against the same expression so a later
|
||||
// AlarmAckByGUID has a live consumer to act through. It is an opt-in
|
||||
// enhancement, so a failed lifecycle drops back to the wnwrap ack path
|
||||
// rather than failing the alarm subscription.
|
||||
if (managedAck is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
managedAck.Subscribe(subscription);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { managedAck.Dispose(); } catch { /* swallow */ }
|
||||
managedAck = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the consumer is currently serving alarms from the subtag
|
||||
/// fallback, whose alarm GUIDs are synthetic and unknown to alarmmgr.
|
||||
/// </summary>
|
||||
private bool IsSubtagActive() =>
|
||||
consumer is SubtagAlarmConsumer ||
|
||||
(consumer is FailoverAlarmConsumer failover && failover.Mode == AlarmProviderMode.Subtag);
|
||||
|
||||
/// <summary>
|
||||
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
|
||||
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
|
||||
/// status code (0 = success).
|
||||
/// </summary>
|
||||
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
if (managedAck is not null && !IsSubtagActive())
|
||||
{
|
||||
// Preferred path: AVEVA's modern AlarmClient.AlarmAckByGUID carries
|
||||
// operator domain + full name and is not E_NOTIMPL like the wnwrap
|
||||
// COM AlarmAckByGUID. Skipped in subtag mode: those GUIDs are
|
||||
// synthetic and only the subtag consumer can resolve them (it acks
|
||||
// via an AckMsg write).
|
||||
return managedAck.AcknowledgeByGuid(
|
||||
alarmGuid,
|
||||
ackComment,
|
||||
ackOperatorName,
|
||||
ackOperatorNode,
|
||||
ackOperatorDomain,
|
||||
ackOperatorFullName);
|
||||
}
|
||||
|
||||
return consumer.AcknowledgeByGuid(
|
||||
alarmGuid,
|
||||
ackComment,
|
||||
ackOperatorName,
|
||||
ackOperatorNode,
|
||||
ackOperatorDomain,
|
||||
ackOperatorFullName);
|
||||
AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
|
||||
return target.Found
|
||||
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
||||
: AlarmAckResult.FromTarget(target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge an alarm by its (name, provider, group) tuple.
|
||||
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
|
||||
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
|
||||
/// </summary>
|
||||
/// <param name="alarmName">The alarm name.</param>
|
||||
/// <param name="providerName">The provider name.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="ackComment">The acknowledgment comment.</param>
|
||||
/// <param name="ackOperatorName">The operator name.</param>
|
||||
/// <param name="ackOperatorNode">The operator node.</param>
|
||||
/// <param name="ackOperatorDomain">The operator domain.</param>
|
||||
/// <param name="ackOperatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
public int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
|
||||
/// <param name="alarmName">The tag/alarm name.</param>
|
||||
/// <param name="providerName">The provider.</param>
|
||||
/// <param name="groupName">The group/area.</param>
|
||||
/// <param name="ackComment">The acknowledgement comment.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as.</param>
|
||||
/// <returns>The outcome.</returns>
|
||||
public AlarmAckResult AcknowledgeByName(
|
||||
string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
return consumer.AcknowledgeByName(
|
||||
alarmName,
|
||||
providerName,
|
||||
groupName,
|
||||
ackComment,
|
||||
ackOperatorName,
|
||||
ackOperatorNode,
|
||||
ackOperatorDomain,
|
||||
ackOperatorFullName);
|
||||
AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
|
||||
return target.Found
|
||||
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
||||
: AlarmAckResult.FromTarget(target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -327,6 +252,5 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
|
||||
try { sink.Detach(); } catch { /* swallow */ }
|
||||
try { consumer.Dispose(); } catch { /* swallow */ }
|
||||
try { managedAck?.Dispose(); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,44 +35,21 @@ public interface IAlarmCommandHandler : IDisposable
|
||||
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
||||
void Unsubscribe();
|
||||
|
||||
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
|
||||
/// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorUser">The operator user name.</param>
|
||||
/// <param name="operatorNode">The operator node name.</param>
|
||||
/// <param name="operatorDomain">The operator domain name.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
|
||||
int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||
/// <returns>The acknowledgement outcome.</returns>
|
||||
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by (name, provider, group) — used when
|
||||
/// the caller has the human-readable reference but not the GUID.
|
||||
/// </summary>
|
||||
/// <param name="alarmName">The alarm name.</param>
|
||||
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
|
||||
/// <param name="alarmName">The alarm (tag) name.</param>
|
||||
/// <param name="providerName">The provider name.</param>
|
||||
/// <param name="groupName">The group name.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorUser">The operator user name.</param>
|
||||
/// <param name="operatorNode">The operator node name.</param>
|
||||
/// <param name="operatorDomain">The operator domain name.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>AVEVA's native alarm status code (0 = success).</returns>
|
||||
int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
/// <param name="comment">The acknowledgement comment written to the alarm's ack item.</param>
|
||||
/// <param name="ackOperator">The operator to authenticate as (credential-sensitive).</param>
|
||||
/// <returns>The acknowledgement outcome.</returns>
|
||||
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently-active alarm set, optionally scoped to a
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Optional ack-only seam over AVEVA's modern managed alarm client
|
||||
/// (<c>aaAlarmManagedClient.AlarmClient</c>). When supplied to an
|
||||
/// <see cref="AlarmDispatcher"/>, GUID-keyed acknowledges route here
|
||||
/// instead of the wnwrap COM consumer's <c>AlarmAckByGUID</c> (which is
|
||||
/// <c>E_NOTIMPL</c> on current builds) — carrying full operator identity
|
||||
/// (domain + full name) that the wnwrap 6-arg <c>AlarmAckByName</c> drops.
|
||||
/// The production implementation is <see cref="AaManagedAlarmAckConsumer"/>.
|
||||
/// </summary>
|
||||
public interface IManagedAlarmAckClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the client's Initialize → Register → Subscribe lifecycle against
|
||||
/// the same subscription expression the read consumer uses
|
||||
/// (<c>\\<node>\Galaxy!<area></c>). Must be called once,
|
||||
/// on the worker STA, before <see cref="AcknowledgeByGuid"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The subscription expression.</param>
|
||||
void Subscribe(string subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by GUID with full operator identity.
|
||||
/// Returns AVEVA's native status (0 = success; 0 means accepted, not
|
||||
/// necessarily applied — verify the alarm's acked state / Historian row).
|
||||
/// </summary>
|
||||
/// <param name="alarmGuid">The alarm GUID.</param>
|
||||
/// <param name="comment">The acknowledgment comment.</param>
|
||||
/// <param name="operatorName">The operator name.</param>
|
||||
/// <param name="operatorNode">The operator node.</param>
|
||||
/// <param name="operatorDomain">The operator domain.</param>
|
||||
/// <param name="operatorFullName">The operator full name.</param>
|
||||
/// <returns>The AVEVA-native status code.</returns>
|
||||
int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
@@ -912,30 +913,25 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
|
||||
}
|
||||
|
||||
// operator_password is a raw credential: it goes into AlarmAckOperator for
|
||||
// MXAccess AuthenticateUser and nowhere else. Never format the payload
|
||||
// (its generated ToString renders every field) or the exception text.
|
||||
try
|
||||
{
|
||||
int rc = alarmCommandHandler.Acknowledge(
|
||||
AlarmAckResult result = alarmCommandHandler.Acknowledge(
|
||||
alarmGuid,
|
||||
payload.Comment,
|
||||
payload.OperatorUser,
|
||||
payload.OperatorNode,
|
||||
payload.OperatorDomain,
|
||||
payload.OperatorFullName);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.Hresult = rc;
|
||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
||||
{
|
||||
NativeStatus = rc,
|
||||
};
|
||||
if (rc != 0)
|
||||
{
|
||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
|
||||
}
|
||||
return reply;
|
||||
new AlarmAckOperator
|
||||
{
|
||||
UserName = payload.OperatorUser,
|
||||
Domain = payload.OperatorDomain,
|
||||
Password = payload.OperatorPassword,
|
||||
});
|
||||
return CreateAcknowledgeReply(command, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
return CreateAcknowledgeFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,35 +954,80 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
|
||||
}
|
||||
|
||||
// See ExecuteAcknowledgeAlarm: operator_password never leaves AlarmAckOperator.
|
||||
try
|
||||
{
|
||||
int rc = alarmCommandHandler.AcknowledgeByName(
|
||||
AlarmAckResult result = alarmCommandHandler.AcknowledgeByName(
|
||||
payload.AlarmName,
|
||||
payload.ProviderName,
|
||||
payload.GroupName,
|
||||
payload.Comment,
|
||||
payload.OperatorUser,
|
||||
payload.OperatorNode,
|
||||
payload.OperatorDomain,
|
||||
payload.OperatorFullName);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.Hresult = rc;
|
||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
||||
{
|
||||
NativeStatus = rc,
|
||||
};
|
||||
if (rc != 0)
|
||||
{
|
||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
|
||||
}
|
||||
return reply;
|
||||
new AlarmAckOperator
|
||||
{
|
||||
UserName = payload.OperatorUser,
|
||||
Domain = payload.OperatorDomain,
|
||||
Password = payload.OperatorPassword,
|
||||
});
|
||||
return CreateAcknowledgeReply(command, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
return CreateAcknowledgeFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
|
||||
{
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.Hresult = result.NativeStatus;
|
||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
|
||||
reply.Statuses.Add(result.Statuses);
|
||||
reply.DiagnosticMessage = result.Diagnostic;
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case AlarmAckOutcome.AlarmNotFound:
|
||||
case AlarmAckOutcome.NotAckable:
|
||||
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
|
||||
break;
|
||||
case AlarmAckOutcome.AuthenticationFailed:
|
||||
case AlarmAckOutcome.WriteRejected:
|
||||
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
|
||||
break;
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an exception escaping an acknowledgement (e.g. the ack writer's
|
||||
/// MXAccess Register failing, or no active subscription) to an
|
||||
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> reply. Only the
|
||||
/// exception type and HRESULT are reported: exception text on this path
|
||||
/// is not under our control and must not be able to echo operator input.
|
||||
/// </summary>
|
||||
private static MxCommandReply CreateAcknowledgeFailureReply(StaCommand command, Exception exception)
|
||||
{
|
||||
string diagnostic = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Alarm acknowledgement failed: {0} (HRESULT 0x{1:X8}).",
|
||||
exception.GetType().FullName,
|
||||
exception.HResult);
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
Hresult = exception.HResult,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.MxaccessFailure,
|
||||
Message = diagnostic,
|
||||
},
|
||||
DiagnosticMessage = diagnostic,
|
||||
AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = exception.HResult },
|
||||
};
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Native alarm-acknowledge path over <c>alarmlst.dll</c>'s public <c>WNAL*</c>
|
||||
/// C exports (the same stack the AVEVA Alarm Control uses:
|
||||
/// <c>aaAlarmManagedClient → CwwAlarmConsumer → alarmlst.dll → alarmmgr.exe</c>).
|
||||
///
|
||||
/// Why this exists alongside <see cref="WnWrapAlarmConsumer"/>: the legacy
|
||||
/// <c>WNWRAPCONSUMERLib</c> COM surface cannot carry a full/secured ack —
|
||||
/// <c>AlarmAckByGUID</c> is <c>E_NOTIMPL</c>, the 8-arg <c>AlarmAckByName</c>
|
||||
/// is a vendor stub (-55), and neither exposes the signed-ack password. The
|
||||
/// native request record built by <see cref="WnalReqSetText"/> has six text
|
||||
/// fields — node, operator, comment, <b>password</b>, domain, full name — so
|
||||
/// this path can do a fully-attributed (and signed) ack.
|
||||
///
|
||||
/// THREADING: these calls are not inherently STA-bound the way the COM
|
||||
/// consumer is, but keep all of them on the worker STA for ordering/parity
|
||||
/// with the rest of the alarm surface (marshal via <c>StaRuntime.InvokeAsync</c>).
|
||||
///
|
||||
/// x86 ONLY: <c>alarmlst.dll</c> is 32-bit; this compiles into the x86 worker.
|
||||
///
|
||||
/// STATUS: the ack-SUBMIT contract (<see cref="WnalReqSetText"/>,
|
||||
/// <see cref="WnalUserRequest"/>, <see cref="WnalUserRequestEx"/>) and the
|
||||
/// request-record layout are confirmed by disassembly (see
|
||||
/// docs/alarm-ack-findings.md in the reverse-engineering workspace). The
|
||||
/// list-ACQUISITION side (obtaining <c>hAlarmList</c> + alarm handles) is
|
||||
/// deliberately NOT implemented — see the note at the end of this class.
|
||||
/// Nothing in the worker calls this type yet; it is kept for when a real
|
||||
/// list handle can be supplied by a component that owns acquisition.
|
||||
/// </summary>
|
||||
public sealed class WnalNativeAckConsumer
|
||||
{
|
||||
private const string AlarmLst = "alarmlst.dll";
|
||||
|
||||
/// <summary>Request-record version stamped at WORD offset 2; WNALUserRequest rejects anything else.</summary>
|
||||
private const ushort AlmAckVersion = 0x104;
|
||||
|
||||
/// <summary>Byte offset where WNALalarmReqSetText begins the string-offset table + heap.</summary>
|
||||
private const int StringHeapBase = 0x42;
|
||||
|
||||
/// <summary>Sentinel the exports reject as an invalid list handle.</summary>
|
||||
public const int InvalidAlarmList = -1;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CONFIRMED exports (disassembly-verified signatures). __stdcall, ANSI.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Pack operator identity + comment into an existing request record.
|
||||
/// Arg order is confirmed from the debug format string. Per-field rule:
|
||||
/// pass <c>null</c> to KEEP the record's existing value, "" to CLEAR it,
|
||||
/// a string to SET it. Strings are single-byte ASCII (strcpy_s).
|
||||
/// Return: positive = bytes used; NEGATIVE = record too small, magnitude
|
||||
/// is the required <c>recSize</c>; 0 = <paramref name="lpAlReq"/> was null.
|
||||
/// </summary>
|
||||
[DllImport(AlarmLst, EntryPoint = "WNALalarmReqSetText",
|
||||
CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
private static extern int WnalReqSetText(
|
||||
IntPtr lpAlReq,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? nodeName,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? oper,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? comment,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? passWd,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? domainName,
|
||||
[MarshalAs(UnmanagedType.LPStr)] string? fullName);
|
||||
|
||||
/// <summary>
|
||||
/// Submit a user request (an ack) over the alarms SELECTED in
|
||||
/// <paramref name="hAlarmList"/>. a2 is reserved (0); a3 is a capacity
|
||||
/// hint (the native client passes the grid row count); a4 is scope
|
||||
/// (0 = all, 1 = selected). Returns 0 on success (provider status),
|
||||
/// non-zero on error.
|
||||
/// </summary>
|
||||
[DllImport(AlarmLst, EntryPoint = "WNALUserRequest",
|
||||
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
|
||||
private static extern int WnalUserRequest(int hAlarmList, uint a2, uint a3, int a4, IntPtr lpAlmAck);
|
||||
|
||||
/// <summary>
|
||||
/// Submit a user request (an ack) against an EXPLICIT array of
|
||||
/// <paramref name="count"/> 4-byte alarm handles — no selection state
|
||||
/// required. <paramref name="a4"/> mirrors the scope/flag scalar.
|
||||
/// Returns 0 on success.
|
||||
/// </summary>
|
||||
[DllImport(AlarmLst, EntryPoint = "WNALUserRequestEx",
|
||||
CallingConvention = CallingConvention.StdCall, ExactSpelling = true)]
|
||||
private static extern int WnalUserRequestEx(
|
||||
int hAlarmList, IntPtr handleArray, int count, uint a4, IntPtr lpAlmAck);
|
||||
|
||||
/// <summary>Acknowledge-scope values for the a4 argument.</summary>
|
||||
private enum AckScope
|
||||
{
|
||||
All = 0,
|
||||
Selected = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a request record and submit an ack for the given alarm
|
||||
/// handles via <see cref="WnalUserRequestEx"/>. Caller supplies a live
|
||||
/// <paramref name="hAlarmList"/> and the handle(s) obtained from the
|
||||
/// list's records (acquisition is not implemented — see the class note).
|
||||
/// Returns the native status (0 == success).
|
||||
/// </summary>
|
||||
/// <param name="hAlarmList">A registered, subscribed alarm-list handle.</param>
|
||||
/// <param name="alarmHandles">The 4-byte alarm handles to acknowledge.</param>
|
||||
/// <param name="comment">Ack comment.</param>
|
||||
/// <param name="operatorName">Operator / user name.</param>
|
||||
/// <param name="operatorNode">Operator node name.</param>
|
||||
/// <param name="operatorDomain">Operator domain (carried natively, unlike the COM path).</param>
|
||||
/// <param name="operatorFullName">Operator full name (carried natively, unlike the COM path).</param>
|
||||
/// <param name="password">Operator password for a signed/secured ack, or null.</param>
|
||||
public int AcknowledgeByHandles(
|
||||
int hAlarmList,
|
||||
int[] alarmHandles,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName,
|
||||
string? password)
|
||||
{
|
||||
if (hAlarmList == InvalidAlarmList)
|
||||
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
|
||||
if (alarmHandles is null || alarmHandles.Length == 0)
|
||||
throw new ArgumentException("At least one alarm handle is required.", nameof(alarmHandles));
|
||||
|
||||
IntPtr record = IntPtr.Zero;
|
||||
IntPtr handles = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
record = BuildAckRecord(
|
||||
nodeName: operatorNode,
|
||||
oper: operatorName,
|
||||
comment: comment,
|
||||
passWd: password,
|
||||
domainName: operatorDomain,
|
||||
fullName: operatorFullName);
|
||||
|
||||
handles = Marshal.AllocHGlobal(alarmHandles.Length * sizeof(int));
|
||||
for (int i = 0; i < alarmHandles.Length; i++)
|
||||
Marshal.WriteInt32(handles, i * sizeof(int), alarmHandles[i]);
|
||||
|
||||
return WnalUserRequestEx(
|
||||
hAlarmList,
|
||||
handles,
|
||||
alarmHandles.Length,
|
||||
(uint)AckScope.Selected,
|
||||
record);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles);
|
||||
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge whatever is currently SELECTED in the list (a4 = Selected)
|
||||
/// via <see cref="WnalUserRequest"/>. Use when selection has already been
|
||||
/// established on the list rather than passing explicit handles.
|
||||
/// </summary>
|
||||
public int AcknowledgeSelected(
|
||||
int hAlarmList,
|
||||
uint rowCountHint,
|
||||
string comment,
|
||||
string operatorName,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName,
|
||||
string? password)
|
||||
{
|
||||
if (hAlarmList == InvalidAlarmList)
|
||||
throw new ArgumentException("Invalid alarm-list handle.", nameof(hAlarmList));
|
||||
|
||||
IntPtr record = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
record = BuildAckRecord(operatorNode, operatorName, comment, password, operatorDomain, operatorFullName);
|
||||
return WnalUserRequest(hAlarmList, a2: 0, a3: rowCountHint, a4: (int)AckScope.Selected, record);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (record != IntPtr.Zero) Marshal.FreeHGlobal(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocate a zeroed request record (header + room for the string heap),
|
||||
/// stamp recSize + version, then let <see cref="WnalReqSetText"/> pack the
|
||||
/// operator/comment text. Returns an unmanaged buffer the caller must free.
|
||||
/// </summary>
|
||||
private static IntPtr BuildAckRecord(
|
||||
string nodeName, string oper, string comment, string? passWd, string domainName, string fullName)
|
||||
{
|
||||
// recSize must be >= 0x42 + sum(strlen+1 of each field). Size generously;
|
||||
// SetText returns a negative "needed size" if this is too small, which we
|
||||
// honor by growing once.
|
||||
int capacity = EstimateCapacity(nodeName, oper, comment, passWd, domainName, fullName);
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
IntPtr buf = Marshal.AllocHGlobal(capacity);
|
||||
try
|
||||
{
|
||||
// Zero the whole record (fixed header 0x04..0x31 stays 0 — WNALUserRequest
|
||||
// does not validate it; it is informational/provider-filled).
|
||||
for (int off = 0; off < capacity; off += sizeof(long))
|
||||
{
|
||||
int remaining = capacity - off;
|
||||
if (remaining >= sizeof(long)) Marshal.WriteInt64(buf, off, 0);
|
||||
else for (int b = off; b < capacity; b++) Marshal.WriteByte(buf, b, 0);
|
||||
}
|
||||
|
||||
Marshal.WriteInt16(buf, 0, (short)(ushort)capacity); // WORD recSize
|
||||
Marshal.WriteInt16(buf, 2, unchecked((short)AlmAckVersion)); // WORD version = 0x104
|
||||
|
||||
int rc = WnalReqSetText(buf, nodeName, oper, comment, passWd, domainName, fullName);
|
||||
if (rc >= 0)
|
||||
return buf;
|
||||
|
||||
// rc < 0 => record too small; |rc| is the required size. Grow and retry.
|
||||
capacity = -rc;
|
||||
Marshal.FreeHGlobal(buf);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Marshal.FreeHGlobal(buf);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("WNALalarmReqSetText could not size the ack record.");
|
||||
}
|
||||
|
||||
private static int EstimateCapacity(params string?[] fields)
|
||||
{
|
||||
int bytes = StringHeapBase;
|
||||
foreach (string? f in fields)
|
||||
bytes += (f is null ? 0 : Encoding.ASCII.GetByteCount(f)) + 1;
|
||||
// Round up with headroom; records are tiny, over-allocation is cheap.
|
||||
return Math.Max(1024, bytes + 64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// LIST ACQUISITION — intentionally NOT implemented here, by RE conclusion.
|
||||
//
|
||||
// The ABIs of WNALSelectEntry (func 0x1d) and WNALGetAlarmRecordEx (func
|
||||
// 0x1c) are recoverable, but their SEMANTICS are not: WNALGetAlarmRecordEx
|
||||
// does not define the alarm-record layout — it hands alarmmgr/the provider a
|
||||
// set of opaque output buffers (four 0x41-byte string buffers, a 0x8b-byte
|
||||
// buffer, scalars) that the PROVIDER fills. So the record's GUID offset and
|
||||
// the 4-byte alarm handle that WNALUserRequestEx consumes are provider-defined
|
||||
// and cannot be read out of alarmlst.dll; likewise WNALSelectEntry's arg
|
||||
// meanings live on the provider side of MakeAlarmMgrFuncCall. Hand-rolling the
|
||||
// acquisition from static RE alone would be guessing which bytes are the
|
||||
// handle/GUID — a wrong guess acks the wrong alarm.
|
||||
//
|
||||
// Therefore DO NOT complete the native acquisition by inference. Obtain the
|
||||
// hAlarmList + handles from a component that already encapsulates
|
||||
// register/query/match correctly:
|
||||
// * Preferred: aaAlarmManagedClient.dll AlarmClient.AlarmAckByGUID
|
||||
// (int AlarmAckByGUID(Guid, comment, oper, node, domain, fullName)) — the
|
||||
// Alarm Control's own code; managed signatures are exact (no ABI risk).
|
||||
// It is internal, so invoke by reflection from the x86 worker.
|
||||
// * Or drive ack through the existing WnWrapAlarmConsumer, which already
|
||||
// owns a registered/subscribed consumer, and fix the auth gap (Stage 0).
|
||||
// Keep the confirmed SUBMIT methods above for the case where a real
|
||||
// hAlarmList + handles are supplied by such a component.
|
||||
// ---------------------------------------------------------------------
|
||||
}
|
||||
Reference in New Issue
Block a user