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;
///
/// 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? 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? 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 seam that builds the used for
/// every acknowledgement. Defaults to an over
/// that registers its own MXAccess server
/// (client name from ).
/// 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.
///
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func consumerFactory,
Action? threadAffinityCheck,
IMxAccessComObjectFactory? comFactory,
Func, IMxAccessAlarmConsumer>? standbyFactory,
Func? 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.ackWriterFactory = ackWriterFactory;
this.deferredAckWriter = new DeferredAckWriter(this);
}
/// 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;
}
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(eventQueue, mapper);
dispatcher = new AlarmDispatcher(consumer, sink, subscribeSessionId, deferredAckWriter);
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 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, ackOperator);
}
///
public AlarmAckResult AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
AlarmAckOperator ackOperator)
{
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
threadAffinityCheck?.Invoke();
AlarmDispatcher d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
ackOperator);
}
///
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 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;
}
}
///
/// The writer handed to each . 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.
///
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));
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();
IAlarmAckWriter? writerToDispose;
lock (syncRoot)
{
writerToDispose = ackWriter;
ackWriter = null;
}
try { writerToDispose?.Dispose(); } catch { /* swallow */ }
}
}