Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs
T

553 lines
25 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
public sealed class MxAccessEventMapper
{
/// <summary>
/// Per-culture cache of the explicit timestamp formats
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
/// derived from the culture's own patterns and the worker host's culture
/// is fixed in practice, so the derivation runs once. The cache assumes
/// <see cref="DateTimeFormatInfo"/> patterns are not customized or
/// mutated at runtime — <see cref="CultureInfo"/> compares by name, so a
/// culture carrying custom patterns is served the formats derived for its
/// stock namesake. That direction is safe: a stale format simply stops
/// matching, and the unchanged general chain below still parses the
/// string.
/// </summary>
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
/// <summary>Cached factory delegate so the dictionary lookup allocates nothing on the event path.</summary>
private static readonly Func<CultureInfo, string[]> TimestampFormatFactory = BuildTimestampFormats;
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>
/// <returns>The mapped OnDataChange event.</returns>
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>
/// <returns>The mapped OnWriteComplete event.</returns>
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>
/// <returns>The mapped OperationComplete event.</returns>
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 alarm-event arguments.
/// The worker's alarm path drives this method from
/// <see cref="MxAccessAlarmEventSink.EnqueueTransition"/> once
/// <see cref="AlarmDispatcher"/> decodes a transition raised by the
/// wnwrap-backed <see cref="WnWrapAlarmConsumer"/>.
/// </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>
/// <param name="degraded">
/// <see langword="true"/> when this transition was synthesized by the
/// subtag-provider fallback rather than the native alarmmgr path.
/// Defaults to <see langword="false"/> to preserve alarmmgr parity.
/// </param>
/// <param name="sourceProvider">
/// The alarm provider that sourced this transition. Defaults to
/// <see cref="AlarmProviderMode.Alarmmgr"/> for the native path.
/// </param>
/// <returns>The mapped OnAlarmTransition event.</returns>
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,
bool degraded = false,
AlarmProviderMode sourceProvider = AlarmProviderMode.Alarmmgr)
{
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,
Degraded = degraded,
SourceProvider = sourceProvider,
};
if (originalRaiseTimestampUtc is { } orts)
{
body.OriginalRaiseTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(orts, DateTimeKind.Utc));
}
mxEvent.OnAlarmTransition = body;
return mxEvent;
}
/// <summary>
/// Creates an OnAlarmProviderModeChanged event from a
/// <see cref="FailoverAlarmConsumer"/> provider switch. The worker's
/// alarm path drives this when the failover composite switches between
/// the alarmmgr primary and the subtag standby, so connected gateway
/// clients can observe the degraded/recovered provider state.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="mode">The provider mode now active after the switch.</param>
/// <param name="reason">Human-readable reason for the switch.</param>
/// <param name="hresult">The COM HRESULT that triggered a failover, or 0 for a clean failback.</param>
/// <param name="atUtc">The UTC instant the switch occurred.</param>
/// <returns>The mapped OnAlarmProviderModeChanged event.</returns>
public MxEvent CreateOnAlarmProviderModeChanged(
string sessionId,
AlarmProviderMode mode,
string reason,
int hresult,
DateTime atUtc)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnAlarmProviderModeChanged,
sessionId,
serverHandle: 0,
itemHandle: 0,
statuses: null);
mxEvent.OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
{
Mode = mode,
Reason = reason ?? string.Empty,
Hresult = hresult,
At = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(atUtc, DateTimeKind.Utc)),
};
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>
/// <returns>The mapped OnBufferedDataChange event.</returns>
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;
}
// MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
// VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
// a VT_DATE — so the variant converter classifies it as a plain
// string and the timestamp would otherwise be dropped. Parse it here
// so the source timestamp still reaches MxEvent. MXAccess formats the
// string in the worker host's local time; see TryParseSourceTimestamp.
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.StringValue
&& TryParseSourceTimestamp(convertedTimestamp.StringValue, out DateTime parsedUtc))
{
mxEvent.SourceTimestamp = Timestamp.FromDateTime(parsedUtc);
return;
}
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
{
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
? convertedTimestamp.RawDiagnostic
: $"{mxEvent.RawStatus}; {convertedTimestamp.RawDiagnostic}";
}
}
/// <summary>
/// Parses an MXAccess <c>OnDataChange</c> timestamp string into a UTC
/// <see cref="DateTime"/>. MXAccess delivers the value as a culture-
/// formatted string rather than a FILETIME or VT_DATE, and formats it
/// in the worker host's <em>local</em> time (verified empirically — a
/// fast-changing tag's timestamp lands the host's UTC offset behind
/// wall-clock UTC). The parsed value is therefore taken as local time
/// and converted to UTC. Tries the worker host's culture first
/// (MXAccess formats with the host locale), then the invariant culture.
/// <para>
/// PERF: every OnDataChange carries one of these strings, and the
/// general <see cref="DateTime.TryParse(string, IFormatProvider, DateTimeStyles, out DateTime)"/>
/// has to walk the culture's whole pattern set to recognize it. The
/// shapes MXAccess actually emits are tried first with
/// <see cref="DateTime.TryParseExact(string, string[], IFormatProvider, DateTimeStyles, out DateTime)"/>.
/// The formats are <em>derived from the current culture's own</em>
/// short-date and long-time patterns (see
/// <see cref="BuildTimestampFormats"/>) rather than hard-coded to the
/// US shapes seen in captures: a hard-coded <c>M/d/yyyy</c> would
/// reorder month and day on a day-first host relative to what the
/// general parse below returns. Deriving them means any string the
/// exact parse accepts is one the culture-aware general parse would
/// have accepted with the same value, so the fast path is a shortcut
/// and never a different answer; anything it does not match falls
/// through to the unchanged two-stage chain.
/// </para>
/// </summary>
/// <param name="text">The MXAccess timestamp string.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
internal static bool TryParseSourceTimestamp(string? text, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
CultureInfo culture = CultureInfo.CurrentCulture;
// Empty when the culture's patterns yielded no format that survived
// validation; that culture just gets no fast path.
string[] formats = TimestampFormatCache.GetOrAdd(culture, TimestampFormatFactory);
DateTime parsed;
if ((formats.Length > 0 && DateTime.TryParseExact(text, formats, culture, styles, out parsed))
|| DateTime.TryParse(text, culture, styles, out parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
return false;
}
/// <summary>
/// Builds the explicit timestamp formats for a culture: the culture's
/// short date plus its long time, and — first, because it is the shape
/// MXAccess actually fires — the same pattern with the millisecond
/// fraction MXAccess appends to the seconds ("3/26/2026 1:38:22.907 PM"
/// under en-US). No culture publishes a long-time pattern with
/// milliseconds, so the fractional sibling is derived by inserting
/// <c>.fff</c> after the seconds specifier; a pattern without a
/// two-digit seconds specifier, or one that already carries a fraction,
/// gets only the published pattern.
/// <para>
/// Both candidates are validated by round-tripping a probe instant
/// before they are handed to the event path, because the derivation
/// reads patterns it does not control: the first <c>ss</c> in a
/// pattern could in principle sit inside quoted literal text, and
/// inserting into it would produce a format that either mangles the
/// value or is outright malformed — and a malformed format makes
/// <see cref="DateTime.ParseExact(string, string, IFormatProvider)"/>
/// and its siblings throw <see cref="FormatException"/> rather than
/// report failure. Validation happens here, once per culture, so the
/// event path never carries that risk; a candidate that does not
/// round-trip is dropped, and a culture that loses both simply gets
/// no fast path.
/// </para>
/// </summary>
/// <param name="culture">Culture whose patterns the formats are derived from.</param>
/// <returns>The validated formats to try, most likely first; empty when none survived.</returns>
private static string[] BuildTimestampFormats(CultureInfo culture)
{
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
string shortDate = formatInfo.ShortDatePattern;
string longTime = formatInfo.LongTimePattern;
List<string> formats = new(2);
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
{
string withMilliseconds = shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff");
if (RoundTripsProbe(withMilliseconds, culture, new DateTime(2026, 3, 26, 13, 38, 22, 907)))
{
formats.Add(withMilliseconds);
}
}
string published = shortDate + " " + longTime;
if (RoundTripsProbe(published, culture, new DateTime(2026, 3, 26, 13, 38, 22)))
{
formats.Add(published);
}
return formats.ToArray();
}
/// <summary>
/// Reports whether a derived format both formats and re-parses a probe
/// instant back to itself under the given culture. A format that throws,
/// fails to parse its own output, or loses part of the instant is
/// rejected — the fast path may only ever be a shortcut.
/// </summary>
/// <param name="format">Candidate format to validate.</param>
/// <param name="culture">Culture the format was derived from.</param>
/// <param name="probe">Instant to round-trip; carries the precision the format must preserve.</param>
/// <returns><see langword="true"/> when the format is safe to use on the event path.</returns>
private static bool RoundTripsProbe(
string format,
CultureInfo culture,
DateTime probe)
{
try
{
string probeText = probe.ToString(format, culture);
return DateTime.TryParseExact(probeText, format, culture, DateTimeStyles.None, out DateTime roundTripped)
&& roundTripped == probe;
}
catch (FormatException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
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;
}
}