From 099d4783b0d6712e445b9e51a4a6516aeaf73fa2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 13:53:38 -0400 Subject: [PATCH] Fix worker dropping the OnDataChange source timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 VT_DATE. VariantConverter classified it as a plain string, so ApplySourceTimestamp never set MxEvent.SourceTimestamp — every OnDataChange event and every cached ReadBulk result carried no source timestamp for all gRPC clients. Parse the string and set SourceTimestamp. MXAccess formats it in the worker host's local time (verified empirically: a fast-changing tag's timestamp landed exactly the host UTC offset behind wall-clock UTC), so it is parsed as local and converted to UTC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MxAccess/MxAccessEventMapperTests.cs | 58 +++++++++++++++++++ .../MxAccess/MxAccessEventMapper.cs | 47 +++++++++++++++ 2 files changed, 105 insertions(+) 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)