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. /// /// /// /// This is the in-process slice of A.3 — it proves the /// consumer→sink→queue pipeline end-to-end without touching the /// worker's IPC command framing. The companion follow-up PR adds /// SubscribeAlarmsCommand / AcknowledgeAlarmCommand / /// QueryActiveAlarmsCommand proto entries plus the gateway- /// side WorkerAlarmRpcDispatcher that issues them. /// /// /// Threading: polls on a /// thread today; production /// hosting should marshal the consumer onto the worker's STA via /// StaRuntime.InvokeAsync. The dispatcher itself is purely /// a pass-through, so it inherits whatever thread the consumer's /// event handler fires on. Fan-out into EnqueueTransition /// uses which is /// 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); } /// /// 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 */ } } }