worker: alarm event mapper + sink scaffold (PR A.2 — partial) #112
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user