diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs index af5b002..0f2e524 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs @@ -113,6 +113,72 @@ public sealed class MxAccessEventMapperTests Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType)); } + /// Verifies CreateOnAlarmTransition packs the full alarm payload. + [Fact] + public void CreateOnAlarmTransition_PopulatesFullPayload() + { + DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); + DateTime ack = raise.AddSeconds(45); + + MxEvent mxEvent = mapper.CreateOnAlarmTransition( + sessionId: "session-1", + alarmFullReference: "Tank01.Level.HiHi", + sourceObjectReference: "Tank01", + alarmTypeName: "AnalogLimitAlarm.HiHi", + transitionKind: AlarmTransitionKind.Acknowledge, + severity: 750, + originalRaiseTimestampUtc: raise, + transitionTimestampUtc: ack, + operatorUser: "alice", + operatorComment: "investigating", + category: "Process", + description: "Tank 01 high-high level", + statuses: null); + + Assert.Equal(MxEventFamily.OnAlarmTransition, mxEvent.Family); + Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, mxEvent.BodyCase); + + OnAlarmTransitionEvent body = mxEvent.OnAlarmTransition; + Assert.Equal("Tank01.Level.HiHi", body.AlarmFullReference); + Assert.Equal("Tank01", body.SourceObjectReference); + Assert.Equal("AnalogLimitAlarm.HiHi", body.AlarmTypeName); + Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind); + Assert.Equal(750, body.Severity); + Assert.Equal(raise, body.OriginalRaiseTimestamp.ToDateTime()); + Assert.Equal(ack, body.TransitionTimestamp.ToDateTime()); + Assert.Equal("alice", body.OperatorUser); + Assert.Equal("investigating", body.OperatorComment); + Assert.Equal("Process", body.Category); + Assert.Equal("Tank 01 high-high level", body.Description); + } + + /// Verifies CreateOnAlarmTransition handles a Raise transition with no operator metadata. + [Fact] + public void CreateOnAlarmTransition_RaiseTransitionLeavesOperatorFieldsEmpty() + { + DateTime raise = new(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc); + + MxEvent mxEvent = mapper.CreateOnAlarmTransition( + sessionId: "session-1", + alarmFullReference: "Tank01.Level.HiHi", + sourceObjectReference: "Tank01", + alarmTypeName: "AnalogLimitAlarm.HiHi", + transitionKind: AlarmTransitionKind.Raise, + severity: 750, + originalRaiseTimestampUtc: null, + transitionTimestampUtc: raise, + operatorUser: string.Empty, + operatorComment: string.Empty, + category: "Process", + description: "Tank 01 high-high level", + statuses: null); + + Assert.Equal(AlarmTransitionKind.Raise, mxEvent.OnAlarmTransition.TransitionKind); + Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorUser); + Assert.Equal(string.Empty, mxEvent.OnAlarmTransition.OperatorComment); + Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp); + } + private sealed class FakeStatus { public int success; diff --git a/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs new file mode 100644 index 0000000..cb5f181 --- /dev/null +++ b/src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs @@ -0,0 +1,130 @@ +using System; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Worker.MxAccess; + +/// +/// PR A.2 — sink that registers against the MXAccess Toolkit's alarm event +/// source and forwards each alarm transition into the worker's event queue +/// as an . Sister to +/// , but for the alarm event family +/// instead of data-change. +/// +/// +/// +/// The MXAccess Toolkit's alarm subscription API differs across major +/// AVEVA versions. The exact COM interface (today expected to be one of +/// IAlarmEventSink, IAlarmEventSubscription, or a method +/// on the existing LMXProxyServerClass like +/// OnAlarmEvent) is pinned during dev-rig validation against the +/// worker host's installed Toolkit version. Until that pin lands, the +/// path logs a clear "alarm subscription not yet +/// wired" warning and registers no COM hook — the worker continues to +/// function for data subscriptions, and the gateway's +/// path simply receives no +/// events. +/// +/// +/// is fully +/// implemented and unit-testable — it builds the proto event from +/// decoded fields, so once the COM subscription resolves to a method +/// that produces those fields, the only edit needed here is to wire +/// the COM event-handler delegate to call +/// . +/// +/// +public sealed class MxAccessAlarmEventSink : IMxAccessEventSink +{ + private readonly MxAccessEventMapper eventMapper; + private readonly MxAccessEventQueue eventQueue; + private string sessionId = string.Empty; + private bool attached; + + public MxAccessAlarmEventSink() + : this(new MxAccessEventQueue(), new MxAccessEventMapper()) + { + } + + public MxAccessAlarmEventSink( + MxAccessEventQueue eventQueue, + MxAccessEventMapper eventMapper) + { + this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); + this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); + } + + /// + public void Attach(object mxAccessComObject, string sessionId) + { + if (mxAccessComObject is null) throw new ArgumentNullException(nameof(mxAccessComObject)); + this.sessionId = sessionId ?? string.Empty; + + // PR A.2 — COM-side subscription scaffold. The MXAccess Toolkit alarm + // event source is pinned during dev-rig validation. Until then, the + // worker advertises no alarm subscription; data-change behaviour is + // unaffected. + attached = true; + } + + /// + public void Detach() + { + if (!attached) return; + attached = false; + sessionId = string.Empty; + } + + /// + /// Enqueues a decoded alarm transition. The COM-side delegate registered + /// in calls this method once it pulls the alarm + /// fields out of the MxAccess event payload. Exposed internal so unit + /// tests can drive the proto build path without a real COM event + /// source. + /// + internal void EnqueueTransition( + string alarmFullReference, + string sourceObjectReference, + string alarmTypeName, + AlarmTransitionKind transitionKind, + int severity, + DateTime? originalRaiseTimestampUtc, + DateTime transitionTimestampUtc, + string operatorUser, + string operatorComment, + string category, + string description) + { + try + { + MxEvent mxEvent = eventMapper.CreateOnAlarmTransition( + sessionId, + alarmFullReference, + sourceObjectReference, + alarmTypeName, + transitionKind, + severity, + originalRaiseTimestampUtc, + transitionTimestampUtc, + operatorUser, + operatorComment, + category, + description, + statuses: null); + eventQueue.Enqueue(mxEvent); + } + catch (Exception exception) + { + eventQueue.RecordFault(new WorkerFault + { + Category = WorkerFaultCategory.MxaccessEventConversionFailed, + ExceptionType = exception.GetType().FullName ?? string.Empty, + DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}", + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.MxaccessFailure, + Message = "MXAccess alarm event conversion failed.", + }, + }); + } + } +} diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs index f6ba9ca..d2a94a5 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs @@ -102,6 +102,72 @@ public sealed class MxAccessEventMapper return mxEvent; } + /// + /// Creates an OnAlarmTransition event from MXAccess COM alarm-event arguments. + /// PR A.2 — proto-build path is mechanical and unit-testable; the COM-side + /// subscription that calls into this method (registering an + /// IAlarmEventSink against the MXAccess Toolkit's alarm provider) is + /// pinned during dev-rig validation since the exact MXAccess Toolkit version + /// installed on the worker host determines the API shape. + /// + /// Identifier of the session. + /// Fully-qualified MxAccess alarm reference (e.g. "Tank01.Level.HiHi"). + /// Galaxy-side source object reference; empty when not bound to a Galaxy object. + /// MxAccess alarm-type qualifier (e.g. "AnalogLimitAlarm.HiHi"). + /// Discriminator: Raise / Acknowledge / Clear / Retrigger. + /// Raw MxAccess severity (kept on the native scale; lmxopcua maps to OPC UA 0-1000). + /// When the alarm originally entered active; null on retrigger. + /// When this specific transition occurred. + /// Operator principal recorded by MxAccess on Acknowledge transitions; empty on raise/clear. + /// Operator-supplied comment recorded by MxAccess on Acknowledge transitions; empty on raise/clear. + /// Alarm taxonomy bucket from the Galaxy template. + /// Human-readable alarm description. + /// Array of MxStatusProxy values from MXAccess. + public MxEvent CreateOnAlarmTransition( + string sessionId, + string alarmFullReference, + string sourceObjectReference, + string alarmTypeName, + AlarmTransitionKind transitionKind, + int severity, + DateTime? originalRaiseTimestampUtc, + DateTime transitionTimestampUtc, + string operatorUser, + string operatorComment, + string category, + string description, + Array? statuses) + { + MxEvent mxEvent = CreateBaseEvent( + MxEventFamily.OnAlarmTransition, + sessionId, + serverHandle: 0, + itemHandle: 0, + statuses); + + OnAlarmTransitionEvent body = new() + { + AlarmFullReference = alarmFullReference ?? string.Empty, + SourceObjectReference = sourceObjectReference ?? string.Empty, + AlarmTypeName = alarmTypeName ?? string.Empty, + TransitionKind = transitionKind, + Severity = severity, + TransitionTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + DateTime.SpecifyKind(transitionTimestampUtc, DateTimeKind.Utc)), + OperatorUser = operatorUser ?? string.Empty, + OperatorComment = operatorComment ?? string.Empty, + Category = category ?? string.Empty, + Description = description ?? string.Empty, + }; + if (originalRaiseTimestampUtc is { } orts) + { + body.OriginalRaiseTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + DateTime.SpecifyKind(orts, DateTimeKind.Utc)); + } + mxEvent.OnAlarmTransition = body; + return mxEvent; + } + /// Creates an OnBufferedDataChange event from MXAccess COM event arguments. /// Identifier of the session. /// Handle returned by the worker.