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..b35534a 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,44 @@ 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. Within array elements, '\' and ';' are escaped as + /// "\\" and "\;" so multi-element string arrays remain unambiguous. + /// + 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. 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).Replace("\\", "\\\\").Replace(";", "\\;")); + } + 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 +695,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..a6e2e59 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,185 @@ 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_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() + { + 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);