Files
mxaccessgw/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs
T
Joseph Doherty 335c952f00 worker: alarm event mapper + sink scaffold (PR A.2 — partial)
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) <noreply@anthropic.com>
2026-04-30 21:16:29 -04:00

323 lines
13 KiB
C#

using System;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
namespace MxGateway.Worker.MxAccess;
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
public sealed class MxAccessEventMapper
{
private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter;
/// <summary>Initializes a new instance of the MxAccessEventMapper class with default converters.</summary>
public MxAccessEventMapper()
: this(new VariantConverter(), new MxStatusProxyConverter())
{
}
/// <summary>Initializes a new instance of the MxAccessEventMapper class with provided converters.</summary>
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
/// <param name="statusProxyConverter">Converter for MXAccess status arrays to MxStatusProxy protobuf messages.</param>
public MxAccessEventMapper(
VariantConverter variantConverter,
MxStatusProxyConverter statusProxyConverter)
{
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter));
}
/// <summary>Creates an OnDataChange event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">Item value received from MXAccess.</param>
/// <param name="quality">Item quality code from MXAccess.</param>
/// <param name="timestamp">Item timestamp from MXAccess.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnDataChange(
string sessionId,
int serverHandle,
int itemHandle,
object? value,
int quality,
object? timestamp,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnDataChange,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.Value = variantConverter.Convert(value);
mxEvent.Quality = quality;
ApplySourceTimestamp(mxEvent, timestamp);
mxEvent.OnDataChange = new OnDataChangeEvent();
return mxEvent;
}
/// <summary>Creates an OnWriteComplete event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnWriteComplete(
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnWriteComplete,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
return mxEvent;
}
/// <summary>Creates an OperationComplete event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOperationComplete(
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OperationComplete,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.OperationComplete = new OperationCompleteEvent();
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>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="rawDataType">Raw MXAccess data type code for the buffered value.</param>
/// <param name="value">Item value received from MXAccess.</param>
/// <param name="quality">Array of quality values from MXAccess.</param>
/// <param name="timestamp">Array of timestamp values from MXAccess.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnBufferedDataChange(
string sessionId,
int serverHandle,
int itemHandle,
int rawDataType,
object? value,
object? quality,
object? timestamp,
Array? statuses)
{
MxDataType dataType = MapMxDataType(rawDataType);
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnBufferedDataChange,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.Value = variantConverter.Convert(value, dataType);
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent
{
DataType = dataType,
RawDataType = rawDataType,
QualityValues = ConvertBufferedArray(quality, MxDataType.Integer),
TimestampValues = ConvertBufferedArray(timestamp, MxDataType.Time),
};
return mxEvent;
}
/// <summary>Maps a raw MXAccess data type code to the MxDataType enum.</summary>
/// <param name="rawDataType">Raw MXAccess data type value to map.</param>
/// <returns>The corresponding MxDataType enum value.</returns>
public static MxDataType MapMxDataType(int rawDataType)
{
return rawDataType switch
{
-1 => MxDataType.Unknown,
0 => MxDataType.NoData,
1 => MxDataType.Boolean,
2 => MxDataType.Integer,
3 => MxDataType.Float,
4 => MxDataType.Double,
5 => MxDataType.String,
6 => MxDataType.Time,
7 => MxDataType.ElapsedTime,
8 => MxDataType.ReferenceType,
9 => MxDataType.StatusType,
10 => MxDataType.Enum,
11 => MxDataType.SecurityClassificationEnum,
12 => MxDataType.DataQualityType,
13 => MxDataType.QualifiedEnum,
14 => MxDataType.QualifiedStruct,
15 => MxDataType.InternationalizedString,
16 => MxDataType.BigString,
17 => MxDataType.End,
_ => MxDataType.Unknown,
};
}
private MxEvent CreateBaseEvent(
MxEventFamily family,
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = new()
{
Family = family,
SessionId = sessionId ?? string.Empty,
ServerHandle = serverHandle,
ItemHandle = itemHandle,
};
mxEvent.Statuses.Add(statusProxyConverter.ConvertMany(statuses));
return mxEvent;
}
private void ApplySourceTimestamp(
MxEvent mxEvent,
object? timestamp)
{
MxValue convertedTimestamp = variantConverter.Convert(timestamp, MxDataType.Time);
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.TimestampValue)
{
mxEvent.SourceTimestamp = convertedTimestamp.TimestampValue;
return;
}
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
{
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
? convertedTimestamp.RawDiagnostic
: $"{mxEvent.RawStatus}; {convertedTimestamp.RawDiagnostic}";
}
}
private MxArray ConvertBufferedArray(
object? value,
MxDataType expectedElementDataType)
{
if (value is Array array)
{
return variantConverter.ConvertArray(array, expectedElementDataType);
}
MxValue converted = variantConverter.Convert(value, expectedElementDataType);
if (converted.KindCase == MxValue.KindOneofCase.ArrayValue)
{
return converted.ArrayValue;
}
MxArray mxArray = new()
{
ElementDataType = converted.DataType,
VariantType = converted.VariantType,
RawElementDataType = converted.RawDataType,
RawDiagnostic = string.IsNullOrWhiteSpace(converted.RawDiagnostic)
? "Buffered MXAccess event argument was not a SAFEARRAY."
: converted.RawDiagnostic,
};
switch (converted.KindCase)
{
case MxValue.KindOneofCase.Int32Value:
mxArray.Int32Values = new Int32Array();
mxArray.Int32Values.Values.Add(converted.Int32Value);
break;
case MxValue.KindOneofCase.Int64Value:
mxArray.Int64Values = new Int64Array();
mxArray.Int64Values.Values.Add(converted.Int64Value);
break;
case MxValue.KindOneofCase.TimestampValue:
mxArray.TimestampValues = new TimestampArray();
mxArray.TimestampValues.Values.Add(converted.TimestampValue);
break;
}
return mxArray;
}
}