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;
/// Maps MXAccess COM events to protobuf MxEvent messages.
public sealed class MxAccessEventMapper
{
///
/// Per-culture cache of the explicit timestamp formats
/// tries before the general parse
/// chain. Keyed by 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
/// patterns are not customized or
/// mutated at runtime — 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.
///
private static readonly ConcurrentDictionary TimestampFormatCache = new();
/// Cached factory delegate so the dictionary lookup allocates nothing on the event path.
private static readonly Func TimestampFormatFactory = BuildTimestampFormats;
private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter;
/// Initializes a new instance of the MxAccessEventMapper class with default converters.
public MxAccessEventMapper()
: this(new VariantConverter(), new MxStatusProxyConverter())
{
}
/// Initializes a new instance of the MxAccessEventMapper class with provided converters.
/// Converter for MXAccess variant values to MxValue protobuf messages.
/// Converter for MXAccess status arrays to MxStatusProxy protobuf messages.
public MxAccessEventMapper(
VariantConverter variantConverter,
MxStatusProxyConverter statusProxyConverter)
{
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter));
}
/// Creates an OnDataChange event from MXAccess COM event arguments.
/// Identifier of the session.
/// Handle returned by the worker.
/// Handle returned by the worker.
/// Item value received from MXAccess.
/// Item quality code from MXAccess.
/// Item timestamp from MXAccess.
/// Array of MxStatusProxy values from MXAccess.
/// The mapped OnDataChange event.
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;
}
/// Creates an OnWriteComplete event from MXAccess COM event arguments.
/// Identifier of the session.
/// Handle returned by the worker.
/// Handle returned by the worker.
/// Array of MxStatusProxy values from MXAccess.
/// The mapped OnWriteComplete event.
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;
}
/// Creates an OperationComplete event from MXAccess COM event arguments.
/// Identifier of the session.
/// Handle returned by the worker.
/// Handle returned by the worker.
/// Array of MxStatusProxy values from MXAccess.
/// The mapped OperationComplete event.
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;
}
///
/// Creates an OnAlarmTransition event from MXAccess alarm-event arguments.
/// The worker's alarm path drives this method from
/// once
/// decodes a transition raised by the
/// wnwrap-backed .
///
/// 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.
///
/// when this transition was synthesized by the
/// subtag-provider fallback rather than the native alarmmgr path.
/// Defaults to to preserve alarmmgr parity.
///
///
/// The alarm provider that sourced this transition. Defaults to
/// for the native path.
///
/// The mapped OnAlarmTransition event.
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;
}
///
/// Creates an OnAlarmProviderModeChanged event from a
/// 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.
///
/// Identifier of the session.
/// The provider mode now active after the switch.
/// Human-readable reason for the switch.
/// The COM HRESULT that triggered a failover, or 0 for a clean failback.
/// The UTC instant the switch occurred.
/// The mapped OnAlarmProviderModeChanged event.
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;
}
/// Creates an OnBufferedDataChange event from MXAccess COM event arguments.
/// Identifier of the session.
/// Handle returned by the worker.
/// Handle returned by the worker.
/// Raw MXAccess data type code for the buffered value.
/// Item value received from MXAccess.
/// Array of quality values from MXAccess.
/// Array of timestamp values from MXAccess.
/// Array of MxStatusProxy values from MXAccess.
/// The mapped OnBufferedDataChange event.
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;
}
/// Maps a raw MXAccess data type code to the MxDataType enum.
/// Raw MXAccess data type value to map.
/// The corresponding MxDataType enum value.
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}";
}
}
///
/// Parses an MXAccess OnDataChange timestamp string into a UTC
/// . MXAccess delivers the value as a culture-
/// formatted string rather than a FILETIME or VT_DATE, and formats it
/// in the worker host's local 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.
///
/// PERF: every OnDataChange carries one of these strings, and the
/// general
/// has to walk the culture's whole pattern set to recognize it. The
/// shapes MXAccess actually emits are tried first with
/// .
/// The formats are derived from the current culture's own
/// short-date and long-time patterns (see
/// ) rather than hard-coded to the
/// US shapes seen in captures: a hard-coded M/d/yyyy 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.
///
///
/// The MXAccess timestamp string.
/// The parsed UTC timestamp on success.
/// when the string parsed successfully.
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;
}
///
/// 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
/// .fff after the seconds specifier; a pattern without a
/// two-digit seconds specifier, or one that already carries a fraction,
/// gets only the published pattern.
///
/// 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 ss 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
///
/// and its siblings throw 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.
///
///
/// Culture whose patterns the formats are derived from.
/// The validated formats to try, most likely first; empty when none survived.
private static string[] BuildTimestampFormats(CultureInfo culture)
{
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
string shortDate = formatInfo.ShortDatePattern;
string longTime = formatInfo.LongTimePattern;
List 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();
}
///
/// 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.
///
/// Candidate format to validate.
/// Culture the format was derived from.
/// Instant to round-trip; carries the precision the format must preserve.
/// when the format is safe to use on the event path.
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;
}
}