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.