Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions OpcUa/Models/MonitoredNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Full-precision, culture-invariant representation of the last value,
/// captured at the same point the display <see cref="Value"/> is set
/// (see <c>SubscriptionManager.FormatRawValue</c>). Used for CSV recording
/// so exported data is lossless and locale-independent, while
/// <see cref="Value"/> remains a culture-aware display string ("F2" for
/// floating point). Arrays are serialized as semicolon-joined elements.
/// </summary>
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
Expand Down
48 changes: 48 additions & 0 deletions OpcUa/SubscriptionManager.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Opc.Ua;
using Opc.Ua.Client;
using Opcilloscope.OpcUa.Models;
Expand Down Expand Up @@ -152,6 +153,7 @@ public async Task<bool> InitializeAsync()
NodeId = nodeId,
DisplayName = displayName,
Value = "(pending)",
RawValue = "(pending)",
StatusCode = 0 // Good
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -396,6 +400,11 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue)
ValueChanged?.Invoke(variable);
}

/// <summary>
/// Formats a value for on-screen display. Intentionally culture-aware and
/// truncated ("F2") for readability; never use this for data export -
/// use <see cref="FormatRawValue"/> instead.
/// </summary>
internal static string FormatValue(object? value)
{
if (value == null) return "null";
Expand All @@ -406,6 +415,44 @@ internal static string FormatValue(object? value)
return value.ToString() ?? "null";
}

/// <summary>
/// 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.
/// </summary>
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<string>(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<uint> handles;
Expand Down Expand Up @@ -648,6 +695,7 @@ public void MarkAllAsStale()
foreach (var variable in staleVariables)
{
variable.Value = "(reconnecting...)";
variable.RawValue = "(reconnecting...)";
variable.StatusCode = StatusCodes.UncertainInitialValue;
}
}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
180 changes: 180 additions & 0 deletions Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Opc.Ua;
using Opcilloscope.OpcUa;

Expand Down Expand Up @@ -241,6 +242,185 @@ public void FormatValue_DoubleFormatting_VariousValues(double input, string expe
}
}

public class FormatRawValueTests
{
/// <summary>
/// Runs an action with the given culture set as both the current and the
/// default thread culture, restoring the originals afterwards.
/// </summary>
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<int>()));
}

[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]
Expand Down
22 changes: 22 additions & 0 deletions Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Opcilloscope.Utilities;

namespace Opcilloscope.Tests.Utilities;
Expand All @@ -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()
{
Expand Down
Loading
Loading