worker: alarm event mapper + sink scaffold (PR A.2 — partial) #112

Merged
dohertj2 merged 1 commits from track-a2-worker-alarm-mapper into main 2026-04-30 21:18:54 -04:00
3 changed files with 262 additions and 0 deletions
@@ -113,6 +113,72 @@ public sealed class MxAccessEventMapperTests
Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType));
}
/// <summary>Verifies CreateOnAlarmTransition packs the full alarm payload.</summary>
[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);
}
/// <summary>Verifies CreateOnAlarmTransition handles a Raise transition with no operator metadata.</summary>
[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;
@@ -0,0 +1,130 @@
using System;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// 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 <see cref="OnAlarmTransitionEvent"/>. Sister to
/// <see cref="MxAccessBaseEventSink"/>, but for the alarm event family
/// instead of data-change.
/// </summary>
/// <remarks>
/// <para>
/// The MXAccess Toolkit's alarm subscription API differs across major
/// AVEVA versions. The exact COM interface (today expected to be one of
/// <c>IAlarmEventSink</c>, <c>IAlarmEventSubscription</c>, or a method
/// on the existing <c>LMXProxyServerClass</c> like
/// <c>OnAlarmEvent</c>) is pinned during dev-rig validation against the
/// worker host's installed Toolkit version. Until that pin lands, the
/// <see cref="Attach"/> 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
/// <see cref="MxEventFamily.OnAlarmTransition"/> path simply receives no
/// events.
/// </para>
/// <para>
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/> 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
/// <see cref="EnqueueTransition"/>.
/// </para>
/// </remarks>
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));
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
public void Detach()
{
if (!attached) return;
attached = false;
sessionId = string.Empty;
}
/// <summary>
/// Enqueues a decoded alarm transition. The COM-side delegate registered
/// in <see cref="Attach"/> 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.
/// </summary>
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.",
},
});
}
}
}
@@ -102,6 +102,72 @@ public sealed class MxAccessEventMapper
return mxEvent;
}
/// <summary>
/// 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
/// <c>IAlarmEventSink</c> 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.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="alarmFullReference">Fully-qualified MxAccess alarm reference (e.g. "Tank01.Level.HiHi").</param>
/// <param name="sourceObjectReference">Galaxy-side source object reference; empty when not bound to a Galaxy object.</param>
/// <param name="alarmTypeName">MxAccess alarm-type qualifier (e.g. "AnalogLimitAlarm.HiHi").</param>
/// <param name="transitionKind">Discriminator: Raise / Acknowledge / Clear / Retrigger.</param>
/// <param name="severity">Raw MxAccess severity (kept on the native scale; lmxopcua maps to OPC UA 0-1000).</param>
/// <param name="originalRaiseTimestampUtc">When the alarm originally entered active; null on retrigger.</param>
/// <param name="transitionTimestampUtc">When this specific transition occurred.</param>
/// <param name="operatorUser">Operator principal recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
/// <param name="operatorComment">Operator-supplied comment recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
/// <param name="description">Human-readable alarm description.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
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;
}
/// <summary>Creates an OnBufferedDataChange event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>