From 4cc4a8fa30305bcfa1c79b517a0f9fc97750c5a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:21:56 +0000 Subject: [PATCH 1/2] Fix culture-sensitive and lossy CSV recording output CSV recordings were not portable across locales and permanently lost precision: - Timestamps used culture-sensitive ToString: fi-FI replaced the ':' format placeholder with '.', and th-TH applied the Buddhist calendar (year 2569), breaking the documented ISO 8601 contract. Both WriteRecord timestamps (snapshot and DateTime.Now fallback) now use CultureInfo.InvariantCulture. The same defect class in default filename timestamps (GenerateDefaultRecordingFilename and ConnectionIdentifier.Generate) is fixed the same way. - The Value column recorded the UI display string ("F2", current culture), truncating every Float/Double to two decimals, writing "42,12" under European locales, and recording arrays as "[5 items]". MonitoredNode now carries RawValue, an invariant-culture full-precision string set alongside the display Value in the notification path (SubscriptionManager.FormatRawValue: round-trip floating point, ISO 8601 "O" for DateTime, InvariantCulture for IFormattable, arrays as semicolon-joined elements). CsvRecordingManager snapshots RawValue (falling back to Value when unset), preserving the immutable RecordSnapshot-at-enqueue-time design and thread-safety guarantees. The UI display string is unchanged. Tests cover hostile cultures (fi-FI, th-TH, de-DE), full-precision round-trips, array serialization, and snapshot immutability of the raw value. README updated to document the export format. https://claude.ai/code/session_012Vopnd9vWkzELveHRgZhie --- OpcUa/Models/MonitoredNode.cs | 11 + OpcUa/SubscriptionManager.cs | 46 +++++ README.md | 2 +- .../OpcUa/SubscriptionManagerTests.cs | 163 +++++++++++++++ .../Utilities/ConnectionIdentifierTests.cs | 22 ++ .../Utilities/CsvRecordingManagerTests.cs | 194 ++++++++++++++++++ Utilities/ConnectionIdentifier.cs | 6 +- Utilities/CsvRecordingManager.cs | 24 ++- 8 files changed, 461 insertions(+), 7 deletions(-) diff --git a/OpcUa/Models/MonitoredNode.cs b/OpcUa/Models/MonitoredNode.cs index b83c061..d468c0d 100644 --- a/OpcUa/Models/MonitoredNode.cs +++ b/OpcUa/Models/MonitoredNode.cs @@ -11,6 +11,17 @@ public class MonitoredNode public NodeId NodeId { get; init; } = ObjectIds.RootFolder; public string DisplayName { get; init; } = string.Empty; public string Value { get; set; } = string.Empty; + + /// + /// Full-precision, culture-invariant representation of the last value, + /// captured at the same point the display is set + /// (see SubscriptionManager.FormatRawValue). Used for CSV recording + /// so exported data is lossless and locale-independent, while + /// remains a culture-aware display string ("F2" for + /// floating point). Arrays are serialized as semicolon-joined elements. + /// + public string RawValue { get; set; } = string.Empty; + public DateTime? Timestamp { get; set; } public uint StatusCode { get; set; } public bool IsGood => StatusCode == 0; // StatusCode.Good = 0 diff --git a/OpcUa/SubscriptionManager.cs b/OpcUa/SubscriptionManager.cs index 8227cbb..4c154f4 100644 --- a/OpcUa/SubscriptionManager.cs +++ b/OpcUa/SubscriptionManager.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Opc.Ua; using Opc.Ua.Client; using Opcilloscope.OpcUa.Models; @@ -152,6 +153,7 @@ public async Task InitializeAsync() NodeId = nodeId, DisplayName = displayName, Value = "(pending)", + RawValue = "(pending)", StatusCode = 0 // Good }; @@ -232,6 +234,7 @@ private async Task ReadInitialValueAsync(MonitoredNode item) if (value != null) { item.Value = FormatValue(value.Value); + item.RawValue = FormatRawValue(value.Value); item.Timestamp = value.SourceTimestamp; item.StatusCode = (uint)value.StatusCode.Code; ValueChanged?.Invoke(item); @@ -384,6 +387,7 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue) var newValue = FormatValue(dataValue.Value); variable.Value = newValue; + variable.RawValue = FormatRawValue(dataValue.Value); variable.Timestamp = dataValue.SourceTimestamp; variable.StatusCode = (uint)dataValue.StatusCode.Code; @@ -396,6 +400,11 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue) ValueChanged?.Invoke(variable); } + /// + /// Formats a value for on-screen display. Intentionally culture-aware and + /// truncated ("F2") for readability; never use this for data export - + /// use instead. + /// internal static string FormatValue(object? value) { if (value == null) return "null"; @@ -406,6 +415,42 @@ internal static string FormatValue(object? value) return value.ToString() ?? "null"; } + /// + /// Formats a value for data export (CSV recording): full precision, + /// culture-invariant. Floating point uses round-trip formatting, DateTime + /// uses ISO 8601 ("O"), other IFormattable types use InvariantCulture, and + /// arrays are serialized as their actual elements joined with ';' + /// (e.g. "1;2;3"), so the field never needs CSV comma-escaping for the + /// separator itself. + /// + internal static string FormatRawValue(object? value) + { + if (value == null) return "null"; + if (value is string s) return s; + // Round-trip floating point: default ToString is shortest round-trippable + // on modern .NET; pin the culture so the decimal separator is always '.'. + if (value is float f) return f.ToString(CultureInfo.InvariantCulture); + if (value is double d) return d.ToString(CultureInfo.InvariantCulture); + // ISO 8601 round-trip format for timestamps embedded in values. + if (value is DateTime dt) return dt.ToString("O", CultureInfo.InvariantCulture); + if (value is Array arr) + { + // Serialize the actual elements (semicolon-joined) instead of the + // lossy "[N items]" display placeholder. + var parts = new List(arr.Length); + foreach (var element in arr) + { + parts.Add(FormatRawValue(element)); + } + return string.Join(";", parts); + } + if (value is IFormattable formattable) + { + return formattable.ToString(null, CultureInfo.InvariantCulture); + } + return value.ToString() ?? "null"; + } + public async Task ClearAsync() { List handles; @@ -648,6 +693,7 @@ public void MarkAllAsStale() foreach (var variable in staleVariables) { variable.Value = "(reconnecting...)"; + variable.RawValue = "(reconnecting...)"; variable.StatusCode = StatusCodes.UncertainInitialValue; } } diff --git a/README.md b/README.md index 13d77de..c3fe8a1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Browse, monitor, and subscribe to industrial automation data right from your ter - **Monitor** — Subscribe to variables with `Enter`. Real-time updates via OPC UA pub/sub, not polling. - **Inspect** — Full node attributes: Description, DataType, AccessLevel, ValueRank. - **Scope** — Real-time multi-signal oscilloscope (up to 5 signals, 30 s sliding window). -- **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured. +- **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured at full precision in a locale-independent format (ISO 8601 timestamps, `.` decimal separator, arrays as semicolon-joined elements). - **Configure** — Save/load connection and subscription configs (`.cfg` JSON files). - **Themes** — Dark (default) and light. diff --git a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs index e4a945a..3804e11 100644 --- a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs +++ b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Opc.Ua; using Opcilloscope.OpcUa; @@ -241,6 +242,168 @@ public void FormatValue_DoubleFormatting_VariousValues(double input, string expe } } +public class FormatRawValueTests +{ + /// + /// Runs an action with the given culture set as both the current and the + /// default thread culture, restoring the originals afterwards. + /// + private static void WithCulture(string cultureName, Action action) + { + var culture = new CultureInfo(cultureName); + var originalCurrent = CultureInfo.CurrentCulture; + var originalDefault = CultureInfo.DefaultThreadCurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + action(); + } + finally + { + CultureInfo.CurrentCulture = originalCurrent; + CultureInfo.DefaultThreadCurrentCulture = originalDefault; + } + } + + [Fact] + public void FormatRawValue_Null_ReturnsNullString() + { + Assert.Equal("null", SubscriptionManager.FormatRawValue(null)); + } + + [Fact] + public void FormatRawValue_String_ReturnsAsIs() + { + Assert.Equal("Hello, World", SubscriptionManager.FormatRawValue("Hello, World")); + } + + [Fact] + public void FormatRawValue_Double_PreservesFullPrecision() + { + // Display format truncates to "2.72"; raw must keep full precision. + Assert.Equal("2.71828", SubscriptionManager.FormatRawValue(2.71828)); + } + + [Fact] + public void FormatRawValue_Float_PreservesFullPrecision() + { + Assert.Equal("3.14159", SubscriptionManager.FormatRawValue(3.14159f)); + } + + [Fact] + public void FormatRawValue_Double_RoundTrips() + { + var original = 1.0 / 3.0; + + var text = SubscriptionManager.FormatRawValue(original); + var parsed = double.Parse(text, CultureInfo.InvariantCulture); + + Assert.Equal(original, parsed); + } + + [Fact] + public void FormatRawValue_Float_RoundTrips() + { + var original = 0.1f * 7f; + + var text = SubscriptionManager.FormatRawValue(original); + var parsed = float.Parse(text, CultureInfo.InvariantCulture); + + Assert.Equal(original, parsed); + } + + [Theory] + [InlineData("fi-FI")] + [InlineData("de-DE")] + [InlineData("th-TH")] + public void FormatRawValue_Double_UsesDotDecimalSeparator_UnderHostileCulture(string cultureName) + { + WithCulture(cultureName, () => + { + var result = SubscriptionManager.FormatRawValue(42.12); + + // Exact ordinal comparison: '.' decimal separator, never ','. + // (Avoid Assert.DoesNotContain(string) here - its default + // comparison is culture-sensitive and th-TH collation treats + // punctuation as ignorable.) + Assert.Equal("42.12", result); + }); + } + + [Fact] + public void FormatRawValue_Decimal_UsesInvariantCulture() + { + WithCulture("de-DE", () => + { + Assert.Equal("1234.5678", SubscriptionManager.FormatRawValue(1234.5678m)); + }); + } + + [Fact] + public void FormatRawValue_DateTime_UsesIso8601RoundTripFormat() + { + WithCulture("th-TH", () => + { + var value = new DateTime(2026, 1, 6, 14, 30, 45, 678, DateTimeKind.Utc); + + var result = SubscriptionManager.FormatRawValue(value); + + // ISO 8601, Gregorian year (not Buddhist 2569), ':' separators. + Assert.Equal("2026-01-06T14:30:45.6780000Z", result); + }); + } + + [Fact] + public void FormatRawValue_IntArray_SerializesElementsSemicolonJoined() + { + Assert.Equal("1;2;3;4;5", SubscriptionManager.FormatRawValue(new[] { 1, 2, 3, 4, 5 })); + } + + [Fact] + public void FormatRawValue_DoubleArray_SerializesElementsInvariantly() + { + WithCulture("fi-FI", () => + { + Assert.Equal("1.1;2.2;3.3", SubscriptionManager.FormatRawValue(new[] { 1.1, 2.2, 3.3 })); + }); + } + + [Fact] + public void FormatRawValue_StringArray_SerializesElements() + { + Assert.Equal("a;b;c", SubscriptionManager.FormatRawValue(new[] { "a", "b", "c" })); + } + + [Fact] + public void FormatRawValue_EmptyArray_ReturnsEmptyString() + { + Assert.Equal(string.Empty, SubscriptionManager.FormatRawValue(Array.Empty())); + } + + [Fact] + public void FormatRawValue_ByteArray_SerializesElements() + { + Assert.Equal("1;2;255", SubscriptionManager.FormatRawValue(new byte[] { 1, 2, 255 })); + } + + [Fact] + public void FormatRawValue_Boolean_FormatsAsTrueFalse() + { + Assert.Equal("True", SubscriptionManager.FormatRawValue(true)); + Assert.Equal("False", SubscriptionManager.FormatRawValue(false)); + } + + [Fact] + public void FormatRawValue_Integer_FormatsInvariantly() + { + WithCulture("de-DE", () => + { + Assert.Equal("1234567", SubscriptionManager.FormatRawValue(1234567)); + }); + } +} + public class NodeIdExtensionsTests { [Fact] diff --git a/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs b/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs index 8a6e808..9f42160 100644 --- a/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Opcilloscope.Utilities; namespace Opcilloscope.Tests.Utilities; @@ -17,6 +18,27 @@ public void Generate_WithOpcTcpUrl_ReturnsHostPortTimestamp() Assert.Equal("192.168.1.67-50000_20260107_1234", result); } + [Fact] + public void Generate_UsesGregorianCalendar_UnderThaiCulture() + { + // th-TH defaults to the Buddhist calendar (2026 -> 2569); the + // identifier timestamp must stay Gregorian regardless of locale. + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("th-TH"); + var timestamp = new DateTime(2026, 1, 7, 12, 34, 0); + + var result = ConnectionIdentifier.Generate("opc.tcp://localhost:4840", timestamp); + + Assert.Equal("localhost-4840_20260107_1234", result); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + [Fact] public void Generate_WithLocalhost_ReturnsHostPortTimestamp() { diff --git a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs index c5558e1..f23f2b7 100644 --- a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Opc.Ua; using Opcilloscope.OpcUa.Models; using Opcilloscope.Utilities; @@ -506,6 +507,199 @@ public void StopRecording_FlushesQueuedRecordsBeforeClosing() Assert.Equal(count + 1, lines.Length); // header + all records } + /// + /// Runs an action under a hostile culture, set as both the current culture + /// (flows to the background writer task via ExecutionContext) and the + /// default thread culture (covers any thread that does not inherit it). + /// Restored in a finally block so other tests are unaffected. + /// + private static void WithCulture(string cultureName, Action action) + { + var culture = new CultureInfo(cultureName); + var originalCurrent = CultureInfo.CurrentCulture; + var originalDefault = CultureInfo.DefaultThreadCurrentCulture; + try + { + CultureInfo.CurrentCulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + action(); + } + finally + { + CultureInfo.CurrentCulture = originalCurrent; + CultureInfo.DefaultThreadCurrentCulture = originalDefault; + } + } + + [Fact] + public void RecordValue_TimestampIsIso8601_UnderFinnishCulture() + { + // fi-FI replaces the ':' custom-format placeholder with '.', which + // previously produced "14.30.45" instead of ISO 8601 "14:30:45". + WithCulture("fi-FI", () => + { + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "100", + Timestamp = new DateTime(2026, 1, 6, 14, 30, 45, 678) + }; + + _manager.RecordValue(node); + _manager.StopRecording(); // waits for the background writer to drain + + var content = File.ReadAllText(filePath); + Assert.Contains("2026-01-06T14:30:45.678", content); + Assert.DoesNotContain("14.30.45", content); + }); + } + + [Fact] + public void RecordValue_TimestampUsesGregorianCalendar_UnderThaiCulture() + { + // th-TH defaults to the Buddhist calendar (2026 -> 2569), which + // previously leaked into the recorded year. + WithCulture("th-TH", () => + { + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "100", + Timestamp = new DateTime(2026, 1, 6, 14, 30, 45, 678) + }; + + _manager.RecordValue(node); + _manager.StopRecording(); + + var content = File.ReadAllText(filePath); + Assert.Contains("2026-01-06T14:30:45.678", content); + Assert.DoesNotContain("2569", content); + }); + } + + [Fact] + public void RecordValue_NullTimestampFallback_IsIso8601_UnderFinnishCulture() + { + WithCulture("fi-FI", () => + { + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "100", + Timestamp = null // DateTime.Now fallback formatted on the writer thread + }; + + _manager.RecordValue(node); + _manager.StopRecording(); + + var lines = File.ReadAllLines(filePath); + Assert.True(lines.Length >= 2); + var timestamp = lines[1].Split(',')[0]; + // ISO 8601: 'T' separator and ':' time separators (fi-FI would emit '.') + Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$", timestamp); + }); + } + + [Fact] + public void RecordValue_RecordsRawValue_NotTruncatedDisplayValue() + { + // Arrange - display Value is the truncated "F2" string; RawValue holds + // the full-precision invariant representation. The CSV must record RawValue. + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "42.12", + RawValue = "42.123456789012345", + Timestamp = new DateTime(2026, 1, 6, 12, 30, 45, 123) + }; + + // Act + _manager.RecordValue(node); + _manager.StopRecording(); + + // Assert + var content = File.ReadAllText(filePath); + Assert.Contains("42.123456789012345", content); + } + + [Fact] + public void RecordValue_FallsBackToDisplayValue_WhenRawValueIsEmpty() + { + // Arrange - nodes that never had a raw representation set must still record. + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "TestNode", + NodeId = new NodeId(1234), + Value = "fallback-value" + // RawValue left empty + }; + + // Act + _manager.RecordValue(node); + _manager.StopRecording(); + + // Assert + var content = File.ReadAllText(filePath); + Assert.Contains("fallback-value", content); + } + + [Fact] + public void RecordValue_SnapshotsRawValueAtEnqueueTime() + { + // Arrange - the RawValue snapshot must be immutable at enqueue time, + // exactly like the display value snapshot. + var filePath = Path.Combine(_testDirectory, "test.csv"); + _manager.StartRecording(filePath); + var node = new MonitoredNode + { + DisplayName = "SnapNode", + NodeId = new NodeId(1234), + Value = "1.23", + RawValue = "1.2345678" + }; + + // Act - enqueue, then mutate the live node (as the OPC thread would). + _manager.RecordValue(node); + node.RawValue = "9.8765432"; + node.Value = "9.88"; + _manager.StopRecording(); + + // Assert + var content = File.ReadAllText(filePath); + Assert.Contains("1.2345678", content); + Assert.DoesNotContain("9.8765432", content); + } + + [Fact] + public void GenerateDefaultRecordingFilename_UsesGregorianCalendar_UnderThaiCulture() + { + WithCulture("th-TH", () => + { + var filename = CsvRecordingManager.GenerateDefaultRecordingFilename( + "opc.tcp://localhost:4840", 3); + + // The timestamp must use the Gregorian year, not Buddhist (+543). + var gregorianYear = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture); + var buddhistYear = (DateTime.Now.Year + 543).ToString(CultureInfo.InvariantCulture); + Assert.Contains(gregorianYear, filename); + Assert.DoesNotContain(buddhistYear, filename); + }); + } + [Fact] public void StartRecording_ClearsStaleQueueFromPreviousSession() { diff --git a/Utilities/ConnectionIdentifier.cs b/Utilities/ConnectionIdentifier.cs index 32fbe00..b1eeecc 100644 --- a/Utilities/ConnectionIdentifier.cs +++ b/Utilities/ConnectionIdentifier.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Opcilloscope.Utilities; /// @@ -16,7 +18,9 @@ public static class ConnectionIdentifier /// A standardized identifier string (e.g., "192.168.1.67-50000_20260107_1234"). public static string Generate(string? endpointUrl, DateTime? timestamp = null, string timestampFormat = "yyyyMMdd_HHmm") { - var ts = (timestamp ?? DateTime.Now).ToString(timestampFormat); + // InvariantCulture pins the Gregorian calendar and separators so the + // identifier is stable regardless of the user's locale. + var ts = (timestamp ?? DateTime.Now).ToString(timestampFormat, CultureInfo.InvariantCulture); if (string.IsNullOrEmpty(endpointUrl)) return $"config_{ts}"; diff --git a/Utilities/CsvRecordingManager.cs b/Utilities/CsvRecordingManager.cs index fb0a3c4..91235d5 100644 --- a/Utilities/CsvRecordingManager.cs +++ b/Utilities/CsvRecordingManager.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Globalization; using Opcilloscope.OpcUa.Models; namespace Opcilloscope.Utilities; @@ -6,6 +7,10 @@ namespace Opcilloscope.Utilities; /// /// Manages CSV recording of monitored variable value changes. /// Writes data to file in real-time as values change using a background queue. +/// Output is culture-invariant: timestamps are ISO 8601 (Gregorian calendar, +/// '.' decimal / ':' time separators regardless of locale) and values are the +/// full-precision raw representation ('.' decimal separator, arrays as +/// semicolon-joined elements) rather than the truncated UI display string. /// public class CsvRecordingManager : IDisposable { @@ -85,7 +90,9 @@ public static string EnsureRecordingsDirectory() /// A sanitized filename with .csv extension. public static string GenerateDefaultRecordingFilename(string? connectionUrl, int variableCount) { - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + // InvariantCulture pins the Gregorian calendar (e.g. th-TH defaults to + // the Buddhist calendar, which would shift the year by 543). + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); string baseName; if (!string.IsNullOrEmpty(connectionUrl)) @@ -380,11 +387,14 @@ public void RecordValue(MonitoredNode item) // thread mutates the live MonitoredNode in place, so queuing the // reference would let the writer serialize a newer state than was // sampled (duplicated/skipped rows under load). + // Record the full-precision, culture-invariant RawValue rather than the + // truncated ("F2"), culture-aware display Value. Fall back to Value for + // nodes that never had a raw representation set. var snapshot = new RecordSnapshot( item.Timestamp, item.DisplayName, item.NodeId.ToString(), - item.Value, + string.IsNullOrEmpty(item.RawValue) ? item.Value : item.RawValue, item.StatusString); // Queue the snapshot for background writing (non-blocking) @@ -464,9 +474,13 @@ private void WriteRecord(RecordSnapshot item) try { - // Use ISO 8601 timestamp format with milliseconds for precision - var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff") - ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff"); + // Use ISO 8601 timestamp format with milliseconds for precision. + // InvariantCulture is required: the ':' custom-format specifier + // is replaced by the culture's time separator (fi-FI uses '.') + // and the culture's default calendar applies (th-TH uses the + // Buddhist calendar), which would break the ISO 8601 contract. + var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture) + ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture); // Escape values for CSV (handle quotes and commas) var displayName = EscapeCsvField(item.DisplayName); From a751a165056c7144e3eae368046fbf7a6c704faf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 02:50:48 +0000 Subject: [PATCH 2/2] Escape ';' and '\' within array elements in FormatRawValue ["a;b", "c"] previously serialized identically to ["a", "b", "c"], silently misparsing multi-element string arrays on CSV import. https://claude.ai/code/session_012Vopnd9vWkzELveHRgZhie --- OpcUa/SubscriptionManager.cs | 8 +++++--- .../OpcUa/SubscriptionManagerTests.cs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/OpcUa/SubscriptionManager.cs b/OpcUa/SubscriptionManager.cs index 4c154f4..b35534a 100644 --- a/OpcUa/SubscriptionManager.cs +++ b/OpcUa/SubscriptionManager.cs @@ -421,7 +421,8 @@ internal static string FormatValue(object? value) /// uses ISO 8601 ("O"), other IFormattable types use InvariantCulture, and /// arrays are serialized as their actual elements joined with ';' /// (e.g. "1;2;3"), so the field never needs CSV comma-escaping for the - /// separator itself. + /// separator itself. Within array elements, '\' and ';' are escaped as + /// "\\" and "\;" so multi-element string arrays remain unambiguous. /// internal static string FormatRawValue(object? value) { @@ -436,11 +437,12 @@ internal static string FormatRawValue(object? value) if (value is Array arr) { // Serialize the actual elements (semicolon-joined) instead of the - // lossy "[N items]" display placeholder. + // lossy "[N items]" display placeholder. Escape '\' and ';' inside + // elements so ["a;b","c"] is distinguishable from ["a","b","c"]. var parts = new List(arr.Length); foreach (var element in arr) { - parts.Add(FormatRawValue(element)); + parts.Add(FormatRawValue(element).Replace("\\", "\\\\").Replace(";", "\\;")); } return string.Join(";", parts); } diff --git a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs index 3804e11..a6e2e59 100644 --- a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs +++ b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs @@ -375,6 +375,23 @@ public void FormatRawValue_StringArray_SerializesElements() Assert.Equal("a;b;c", SubscriptionManager.FormatRawValue(new[] { "a", "b", "c" })); } + [Fact] + public void FormatRawValue_StringArrayWithSemicolons_EscapesElementSeparators() + { + // ["a;b", "c"] must not collide with ["a", "b", "c"]. + Assert.Equal(@"a\;b;c", SubscriptionManager.FormatRawValue(new[] { "a;b", "c" })); + Assert.NotEqual( + SubscriptionManager.FormatRawValue(new[] { "a", "b", "c" }), + SubscriptionManager.FormatRawValue(new[] { "a;b", "c" })); + } + + [Fact] + public void FormatRawValue_StringArrayWithBackslashes_EscapesBackslashes() + { + // A literal backslash is doubled so it can't be misread as an escape. + Assert.Equal(@"a\\;b\\\;c", SubscriptionManager.FormatRawValue(new[] { @"a\", @"b\;c" })); + } + [Fact] public void FormatRawValue_EmptyArray_ReturnsEmptyString() {