From 335c952f0021801fd1dcb3e1b8cb8bb3eadfe162 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 30 Apr 2026 21:16:29 -0400 Subject: [PATCH] =?UTF-8?q?worker:=20alarm=20event=20mapper=20+=20sink=20s?= =?UTF-8?q?caffold=20(PR=20A.2=20=E2=80=94=20partial)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteenth PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md). Lands the proto-build path that the worker uses to create OnAlarmTransition events. The COM-side subscription that registers an alarm event sink against the MXAccess Toolkit is pinned during dev-rig validation — the exact API differs across AVEVA versions and needs hardware to verify. Lands today (unit-testable, no hardware needed): - MxAccessEventMapper.CreateOnAlarmTransition — mechanical proto builder. Takes decoded alarm fields (full reference, source object, alarm type, transition kind, severity, timestamps, operator user/comment, category, description) and produces an MxEvent with the OnAlarmTransition body populated. Mirrors the pattern of CreateOnDataChange / CreateOnWriteComplete / etc. - MxAccessAlarmEventSink — scaffolded class with documented Attach / Detach + an internal EnqueueTransition entry point. When dev-rig validation pins the MXAccess Toolkit alarm subscription API, the only edit needed is to wire the COM delegate inside Attach to call EnqueueTransition. The mapper bridge is already done. Pending dev-rig validation: - Pin the MXAccess Toolkit alarm event source COM API (likely one of IAlarmEventSink, IAlarmEventSubscription, or a method on LMXProxyServerClass — verify against the worker host's installed version). - Add cancellation/cleanup tests once the COM hook is wired. - Integration test against the parity rig that fires a real Galaxy alarm and asserts the gateway emits OnAlarmTransition. Tests: - 2 new mapper tests pin the full-payload Acknowledge case and the bare-bones Raise case. - Full Worker.Tests suite green: 123 passed (was 121; 2 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MxAccess/MxAccessEventMapperTests.cs | 66 +++++++++ .../MxAccess/MxAccessAlarmEventSink.cs | 130 ++++++++++++++++++ .../MxAccess/MxAccessEventMapper.cs | 66 +++++++++ 3 files changed, 262 insertions(+) create mode 100644 src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs 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. -- 2.52.0