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 */ } } }