using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
///
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
/// (with a wnwrap-backed
/// by default) on the first
/// call, then routes
/// / /
/// through the same instance for the
/// session's lifetime.
///
///
///
/// Construction is dependency-injectable: the consumer factory
/// (default () => new WnWrapAlarmConsumer()) lets tests
/// substitute a fake without touching AVEVA COM. The event queue
/// is supplied by the owning so
/// the alarm-side proto events land on the same queue the worker
/// already drains for IPC dispatch.
///
///
/// Threading: invoked from
/// which runs on the STA. The wnwrap consumer owns no internal
/// timer — the worker's STA drives via
/// StaRuntime.InvokeAsync, so the consumer's transition
/// events fire on the same STA. The
/// 's event handler hands transitions
/// into the thread-safe .
///
///
public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
private readonly MxAccessEventQueue eventQueue;
private readonly Func consumerFactory;
private readonly Action? threadAffinityCheck;
private readonly IMxAccessComObjectFactory? comFactory;
private readonly Func, IMxAccessAlarmConsumer>? standbyFactory;
private readonly Func? managedAckFactory;
private readonly MxAccessEventMapper mapper = new MxAccessEventMapper();
private readonly object syncRoot = new object();
private AlarmDispatcher? dispatcher;
private FailoverAlarmConsumer? failoverConsumer;
private EventHandler? providerModeChangedHandler;
private string subscribeSessionId = string.Empty;
private string subscriptionExpression = string.Empty;
private bool disposed;
/// Initializes a new alarm command handler with the given event queue.
/// The event queue.
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{
}
/// Test seam — inject a custom consumer factory.
/// The event queue.
/// The alarm consumer factory.
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func consumerFactory)
: this(eventQueue, consumerFactory, threadAffinityCheck: null)
{
}
///
/// Production constructor that also injects an
/// STA-affinity guard. is
/// invoked at the entry of every method that touches the underlying
/// (or the wnwrap COM object
/// through it) — , ,
/// , ,
/// , — so an
/// off-STA call raises a programming-error diagnostic instead of
/// deadlocking on cross-apartment marshaling to the
/// ThreadingModel=Apartment wnwrap CLSID. The guard is
/// optional: tests that already drive the handler on a single
/// thread can pass null.
///
/// The event queue.
/// The alarm consumer factory.
/// Optional thread affinity check action.
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func consumerFactory,
Action? threadAffinityCheck)
: this(eventQueue, consumerFactory, threadAffinityCheck, comFactory: null, standbyFactory: null)
{
}
///
/// Full constructor that also threads the MXAccess COM-object factory and
/// an optional standby-consumer seam so the subscribe path can build the
/// subtag / failover consumers required by
/// and
/// .
///
/// The event queue.
///
/// Factory for the PRIMARY (alarmmgr) consumer — the existing
/// wnwrap-backed source. Used alone in alarmmgr mode and as the primary
/// of the failover composite in auto mode.
///
/// Optional STA thread-affinity guard.
///
/// The MXAccess COM-object factory used to build the
/// backing the subtag consumer. May be
/// when a is
/// supplied (tests) or when only the alarmmgr path is ever exercised.
///
///
/// Optional seam that builds the STANDBY (subtag) consumer from a watch
/// list. Defaults to a over an
/// built from
/// . Tests inject a fake so they need no
/// live COM factory.
///
///
/// Optional per-subscription factory for a managed ack client
/// (). When it yields a non-null
/// client, GUID-keyed acknowledges route through AVEVA's modern
/// AlarmClient.AlarmAckByGUID instead of the wnwrap COM path.
/// Defaults to (wnwrap ack). Production supplies
/// , which is a
/// no-op unless MXGATEWAY_USE_MANAGED_ALARM_ACK=1.
///
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func consumerFactory,
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func, IMxAccessAlarmConsumer>? standbyFactory,
Func? managedAckFactory = 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;
}
/// Gets a value indicating whether the handler is subscribed.
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
}
///
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (command is null) throw new ArgumentNullException(nameof(command));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is not null)
{
throw new InvalidOperationException(
"AlarmCommandHandler already has an active subscription; " +
"call Unsubscribe before issuing another SubscribeAlarms command.");
}
subscribeSessionId = sessionId ?? string.Empty;
subscriptionExpression = command.SubscriptionExpression ?? string.Empty;
IMxAccessAlarmConsumer consumer = BuildConsumer(command)
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
// When the selected consumer is a failover composite, surface its
// provider switches onto the worker's event queue so connected
// gateway clients can observe degraded/recovered state. The handler
// is unsubscribed/disposed on Unsubscribe/Dispose below.
if (consumer is FailoverAlarmConsumer failover)
{
failoverConsumer = failover;
providerModeChangedHandler = OnProviderModeChanged;
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);
try
{
dispatcher.Subscribe(subscriptionExpression);
}
catch
{
DetachProviderModeChanged();
try { dispatcher.Dispose(); } catch { /* swallow */ }
dispatcher = null;
throw;
}
}
}
///
/// Selects and builds the alarm consumer from the command's
/// and
/// :
///
/// -
/// Alarmmgr, or Unspecified with an empty watch
/// list: the existing primary (alarmmgr) consumer only —
/// today's behavior.
///
/// -
/// Subtag: a only.
///
/// -
/// Unspecified with a non-empty watch list (auto): a
/// over the primary and a
/// subtag standby.
///
///
///
private IMxAccessAlarmConsumer BuildConsumer(SubscribeAlarmsCommand command)
{
List watchList = new List(command.WatchList);
if (command.ForcedMode == AlarmProviderMode.Subtag)
{
return BuildStandby(watchList);
}
if (command.ForcedMode == AlarmProviderMode.Unspecified
&& (command.FallbackEnabled || watchList.Count > 0))
{
IMxAccessAlarmConsumer primary = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
IMxAccessAlarmConsumer standby = BuildStandby(watchList);
AlarmFailoverConfig? failoverConfig = command.Failover;
FailoverSettings settings = new FailoverSettings(
failoverConfig?.ConsecutiveFailureThreshold ?? 3,
failoverConfig?.FailbackProbeIntervalSeconds ?? 30,
failoverConfig?.FailbackStableProbes ?? 3);
return new FailoverAlarmConsumer(primary, standby, settings);
}
// Alarmmgr, or Unspecified with an empty watch list — primary only.
return consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
}
///
public void UpdateWatchList(IReadOnlyList watchList)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (watchList is null) throw new ArgumentNullException(nameof(watchList));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is null || failoverConsumer is null)
{
throw new InvalidOperationException(
"UpdateAlarmWatchList requires an active fallback-capable alarm subscription.");
}
IMxAccessAlarmConsumer replacement = BuildStandby(watchList);
failoverConsumer.ReplaceStandby(replacement, subscriptionExpression);
}
}
private IMxAccessAlarmConsumer BuildStandby(IReadOnlyList watchList)
{
if (standbyFactory is not null)
{
return standbyFactory(watchList)
?? throw new InvalidOperationException("Standby alarm consumer factory returned null.");
}
if (comFactory is null)
{
throw new InvalidOperationException(
"Subtag alarm consumer requires an IMxAccessComObjectFactory; the alarm command "
+ "handler was constructed without one and no standby factory was supplied.");
}
return new SubtagAlarmConsumer(new LmxSubtagAlarmSource(comFactory), watchList);
}
private void OnProviderModeChanged(object? sender, AlarmProviderModeChange change)
{
if (change is null) return;
eventQueue.Enqueue(mapper.CreateOnAlarmProviderModeChanged(
subscribeSessionId, change.Mode, change.Reason, change.HResult, change.AtUtc));
}
private void DetachProviderModeChanged()
{
if (failoverConsumer is not null && providerModeChangedHandler is not null)
{
try { failoverConsumer.ProviderModeChanged -= providerModeChangedHandler; }
catch { /* swallow */ }
}
failoverConsumer = null;
providerModeChangedHandler = null;
}
///
public void Unsubscribe()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? toDispose;
lock (syncRoot)
{
DetachProviderModeChanged();
toDispose = dispatcher;
dispatcher = null;
subscriptionExpression = string.Empty;
}
toDispose?.Dispose();
}
///
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
///
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
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);
}
///
public IReadOnlyList QueryActive(
string? alarmFilterPrefix,
out bool snapshotTruncated)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
// The verdict rides out of the same call that produced the records, so
// filtering below cannot separate it from the set it describes.
IReadOnlyList all = d.SnapshotActiveAlarms(out snapshotTruncated);
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List filtered = new List(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
{
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
{
filtered.Add(snap);
}
}
return filtered;
}
///
public void PollOnce()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
// No-op when not yet subscribed or already disposed.
d?.PollOnce();
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
if (d is null)
{
throw new InvalidOperationException(
"AlarmCommandHandler has no active subscription; " +
"call SubscribeAlarms before issuing alarm-related commands.");
}
return d;
}
///
public void Dispose()
{
if (disposed) return;
disposed = true;
Unsubscribe();
}
}