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(); } }