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)