diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs index 0f2e524..5b48a31 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using MxGateway.Contracts.Proto; using MxGateway.Worker.MxAccess; @@ -179,6 +180,63 @@ public sealed class MxAccessEventMapperTests Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp); } + /// + /// Verifies that an OnDataChange whose timestamp arrives as the + /// VT_BSTR string MXAccess actually delivers still populates + /// — the string is parsed as + /// local time and converted to UTC. + /// + [Fact] + public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp() + { + // The exact shape MXAccess fires (see captures/003-subscribe-scalars). + const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM"; + + MxEvent mxEvent = mapper.CreateOnDataChange( + "session-1", + serverHandle: 1, + itemHandle: 1, + value: 99, + quality: 192, + timestamp: mxAccessTimestamp, + statuses: null); + + Assert.NotNull(mxEvent.SourceTimestamp); + + DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified); + DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); + Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime()); + } + + /// + /// Verifies the MXAccess timestamp string is interpreted as the host's + /// local time and returned as UTC. Written timezone-independently by + /// round-tripping a local wall-clock time. + /// + [Fact] + public void TryParseSourceTimestamp_InterpretsStringAsLocalTime() + { + DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified); + string text = localWall.ToString(CultureInfo.CurrentCulture); + + Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc)); + Assert.Equal(DateTimeKind.Utc, utc.Kind); + + DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(); + Assert.Equal(expectedUtc, utc); + } + + /// Verifies unparseable or empty timestamp input is rejected without throwing. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a timestamp")] + public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text) + { + Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _)); + } + private sealed class FakeStatus { public int success; diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs index 6712020..585a651 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs @@ -1,4 +1,6 @@ using System; +using System.Globalization; +using Google.Protobuf.WellKnownTypes; using MxGateway.Contracts.Proto; using MxGateway.Worker.Conversion; @@ -265,6 +267,19 @@ public sealed class MxAccessEventMapper 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) @@ -273,6 +288,38 @@ public sealed class MxAccessEventMapper } } + /// + /// 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. + /// + /// 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; + if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed) + || DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed)) + { + utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + return true; + } + + return false; + } + private MxArray ConvertBufferedArray( object? value, MxDataType expectedElementDataType)