using System; using System.Collections.Generic; using MxGateway.Contracts.Proto; namespace MxGateway.Worker.MxAccess; /// /// In-process dispatcher that owns the lifetime of an /// + /// pair, and wires the consumer's AlarmTransitionEmitted stream /// onto the sink's EnqueueTransition path so transitions land on /// the worker's as proto /// messages ready for IPC dispatch. /// /// /// /// The dispatcher carries the consumer→sink→queue pipeline. The /// worker's IPC layer issues SubscribeAlarmsCommand / /// AcknowledgeAlarmCommand / QueryActiveAlarmsCommand /// through , which owns one /// dispatcher per session. /// /// /// Threading: owns no internal /// timer — the worker's STA drives polling via /// StaRuntime.InvokeAsync(() => PollOnce()), so the /// consumer's AlarmTransitionEmitted event fires on the STA. /// The dispatcher is purely a pass-through, so it inherits that /// thread. Fan-out into EnqueueTransition uses the /// thread-safe . /// /// public sealed class AlarmDispatcher : IDisposable { private readonly IMxAccessAlarmConsumer consumer; private readonly MxAccessAlarmEventSink sink; private readonly string sessionId; private readonly EventHandler handler; private bool disposed; public AlarmDispatcher( IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId) { this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer)); this.sink = sink ?? throw new ArgumentNullException(nameof(sink)); this.sessionId = sessionId ?? string.Empty; // 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 // for the alarm path, but the existing IMxAccessEventSink contract // requires a non-null first arg. this.sink.Attach(this.consumer, this.sessionId); this.handler = OnTransition; consumer.AlarmTransitionEmitted += handler; } /// /// Begin polling the configured AVEVA alarm provider for /// transitions. The supplied subscription expression follows the /// canonical \\<machine>\Galaxy!<area> format. /// public void Subscribe(string subscription) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); consumer.Subscribe(subscription); } /// /// Forward an AcknowledgeAlarm request to the underlying /// consumer's AlarmAckByGUID. Returns the AVEVA-native /// status code (0 = success). /// public int Acknowledge( Guid alarmGuid, string ackComment, string ackOperatorName, string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); return consumer.AcknowledgeByGuid( alarmGuid, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); } /// /// Acknowledge an alarm by its (name, provider, group) tuple. /// Routes to the consumer's AcknowledgeByName path which /// maps to wwAlarmConsumerClass.AlarmAckByName. /// public int AcknowledgeByName( string alarmName, string providerName, string groupName, string ackComment, string ackOperatorName, string ackOperatorNode, string ackOperatorDomain, string ackOperatorFullName) { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); return consumer.AcknowledgeByName( alarmName, providerName, groupName, ackComment, ackOperatorName, ackOperatorNode, ackOperatorDomain, ackOperatorFullName); } /// /// Drives a single synchronous poll of the underlying consumer. /// Must be called on the STA thread that owns the wnwrap COM object. /// No-op if the dispatcher has been disposed. /// public void PollOnce() { if (disposed) return; consumer.PollOnce(); } /// /// Snapshot the currently-active alarm set as /// protos for the /// QueryActiveAlarms RPC's ConditionRefresh stream. /// public IReadOnlyList SnapshotActiveAlarms() { if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); IReadOnlyList records = consumer.SnapshotActiveAlarms(); if (records.Count == 0) return Array.Empty(); List snapshots = new List(records.Count); foreach (MxAlarmSnapshotRecord record in records) { snapshots.Add(MapToSnapshot(record)); } return snapshots; } private void OnTransition(object? sender, MxAlarmTransitionEvent transition) { if (disposed) return; if (transition is null) return; MxAlarmSnapshotRecord record = transition.Record; AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition( transition.PreviousState, record.State); if (kind == AlarmTransitionKind.Unspecified) return; string fullReference = AlarmRecordTransitionMapper.ComposeFullReference( record.ProviderName, record.Group, record.TagName); sink.EnqueueTransition( alarmFullReference: fullReference, sourceObjectReference: record.TagName, alarmTypeName: record.Type, transitionKind: kind, severity: record.Priority, originalRaiseTimestampUtc: null, transitionTimestampUtc: record.TransitionTimestampUtc, operatorUser: record.OperatorName, operatorComment: record.AlarmComment, category: record.Group, description: string.Empty); } private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record) { ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot { AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference( record.ProviderName, record.Group, record.TagName), SourceObjectReference = record.TagName, AlarmTypeName = record.Type, CurrentState = MapConditionState(record.State), Severity = record.Priority, OperatorUser = record.OperatorName, OperatorComment = record.AlarmComment, Category = record.Group, Description = string.Empty, }; if (record.TransitionTimestampUtc != DateTime.MinValue) { snapshot.LastTransitionTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( DateTime.SpecifyKind(record.TransitionTimestampUtc, DateTimeKind.Utc)); } return snapshot; } private static AlarmConditionState MapConditionState(MxAlarmStateKind state) { // The proto's AlarmConditionState only distinguishes Active / // ActiveAcked / Inactive — both Rtn states collapse to Inactive // (the ack-vs-unack distinction on a cleared alarm is not exposed // through OPC UA's Part 9 condition state model anyway). return state switch { MxAlarmStateKind.UnackAlm => AlarmConditionState.Active, MxAlarmStateKind.AckAlm => AlarmConditionState.ActiveAcked, MxAlarmStateKind.UnackRtn => AlarmConditionState.Inactive, MxAlarmStateKind.AckRtn => AlarmConditionState.Inactive, _ => AlarmConditionState.Unspecified, }; } public string SessionId => sessionId; public void Dispose() { if (disposed) return; disposed = true; try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ } try { sink.Detach(); } catch { /* swallow */ } try { consumer.Dispose(); } catch { /* swallow */ } } }