Skip to content
Draft
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
9 changes: 5 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
bin/
obj/
publish/
publish-e2e/

# IDE
.vs/
Expand All @@ -20,9 +21,6 @@ Thumbs.db
# Test results
TestResults/

# Node modules (test server)
test-server/node_modules/

# Logs
*.log
nul
Expand All @@ -39,4 +37,7 @@ appsettings.*.json

# Claude Code local settings
.claude/settings.local.json
publish/

# Local review automation and generated working reports
.claude/workflows/
docs/V1-REVIEW.md
4 changes: 2 additions & 2 deletions App/Dialogs/SaveConfigDialog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public SaveConfigDialog(string defaultDirectory, string defaultFilename)
{
X = 1,
Y = 6,
Text = $"Save as type: Opcilloscope Config (*{ConfigurationService.ConfigFileExtension})",
Text = $"Save as type: opcilloscope config (*{ConfigurationService.ConfigFileExtension})",
}.WithScheme(theme.MainColorScheme);

// Info hint about preserving filename
Expand Down Expand Up @@ -158,7 +158,7 @@ private void OnBrowseDirectory(object? sender, CommandEventArgs e)
Title = "Browse for Save Location",
AllowedTypes = new List<IAllowedType>
{
new AllowedType("Opcilloscope Config", ConfigurationService.ConfigFileExtension)
new AllowedType("opcilloscope config", ConfigurationService.ConfigFileExtension)
},
Path = _currentDirectory,
// We'll let user navigate to any directory and extract the directory path
Expand Down
46 changes: 20 additions & 26 deletions App/MainWindow.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Reflection;
using Terminal.Gui;
using Opcilloscope.App.Keybindings;
using Opcilloscope.App.Views;
Expand Down Expand Up @@ -1356,7 +1355,7 @@ private void ShowHelp()

private void ShowAbout()
{
var version = GetDisplayVersion();
var version = VersionInfo.DisplayVersion;
var titleLine = $"opcilloscope v{version}";
var titlePadded = titleLine.PadLeft((38 + titleLine.Length) / 2).PadRight(38);

Expand Down Expand Up @@ -1385,28 +1384,6 @@ industrial automation data in real-time.
TerminalUi.Query("About opcilloscope", about, "OK");
}

/// <summary>
/// Gets the application version for display. MinVer writes the full semver to
/// <see cref="AssemblyInformationalVersionAttribute"/> (AssemblyVersion is frozen at
/// MAJOR.0.0.0), so prefer that and strip any "+commitsha" build metadata.
/// </summary>
private static string GetDisplayVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;

if (!string.IsNullOrEmpty(informational))
{
var metadataIndex = informational.IndexOf('+');
return metadataIndex >= 0 ? informational[..metadataIndex] : informational;
}

// Fall back to the assembly version if the attribute is missing
return assembly.GetName().Version?.ToString(3) ?? "0.0.0";
}

#region Configuration File Handling

/// <summary>
Expand Down Expand Up @@ -1573,7 +1550,10 @@ private async Task LoadConfigurationCoreAsync(
{
try
{
var nodeId = Opc.Ua.NodeId.Parse(node.NodeId);
var namespaceUris = _connectionManager.Client.Session?.NamespaceUris
?? throw new InvalidOperationException(
"Connected session does not expose a namespace table");
var nodeId = ConfigurationService.ResolveNodeId(node, namespaceUris);
var restored = await _connectionManager.SubscribeAsync(nodeId, node.DisplayName);
if (restored is null)
{
Expand Down Expand Up @@ -1730,7 +1710,8 @@ private async Task SaveConfigurationCoreAsync(string filePath)
_currentMetadata,
_connectionManager.Credentials,
existingServer: activeServer,
existingSettings: activeSettings
existingSettings: activeSettings,
namespaceUris: _connectionManager.Client.Session?.NamespaceUris
);

// Update metadata name from filename if not set
Expand Down Expand Up @@ -1934,6 +1915,19 @@ public void LoadConfigFromCommandLine(string configPath)
});
}

/// <summary>
/// Connects to an endpoint supplied on the command line after the terminal
/// main loop has started.
/// </summary>
public void ConnectFromCommandLine(string endpoint)
{
TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () =>
{
ConnectAsync(endpoint).FireAndForget(_logger);
return false;
});
}

#endregion

#region IKeybindingActions Implementation
Expand Down
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ Platform directories:
## Environment Setup

### .NET SDK Installation
If the `dotnet` command is not available, install .NET 10 SDK using Microsoft's install script:
Install the exact .NET SDK version pinned by `global.json` using Microsoft's install script:

```bash
# Download and run the install script
curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
chmod +x /tmp/dotnet-install.sh
/tmp/dotnet-install.sh --channel 10.0 --install-dir ~/.dotnet
/tmp/dotnet-install.sh --version 10.0.109 --install-dir ~/.dotnet

# Add to PATH for the current session
export PATH="$HOME/.dotnet:$PATH"
Expand Down Expand Up @@ -63,14 +63,16 @@ Usage: opcilloscope [options] [file]

Options:
-f, --config <file> Load configuration file (.cfg, .opcilloscope, or .json)
-c, --connect <url> Reserved; direct URL connection is not yet implemented
-c, --connect <url> Connect directly to an OPC UA endpoint
--insecure Accept untrusted server certificates (development only)
-V, --version Show version information
-h, --help Show help message

Examples:
opcilloscope Start with empty configuration
opcilloscope production.cfg Load configuration file
opcilloscope --config config.json Load configuration file
opcilloscope --connect opc.tcp://localhost:4840
```

The Linux-only `Tests/Opcilloscope.E2ETests` project intentionally stays out
Expand Down Expand Up @@ -219,6 +221,7 @@ Opcilloscope uses JSON-based configuration files with the `.cfg` extension:
"monitoredNodes": [
{
"nodeId": "ns=2;s=Counter",
"namespaceUri": "urn:example:machine",
"displayName": "Counter",
"enabled": true
}
Expand All @@ -232,6 +235,10 @@ Opcilloscope uses JSON-based configuration files with the `.cfg` extension:
}
```

Newly saved non-standard nodes include `namespaceUri`. The URI is stable across
sessions; the numeric namespace index inside `nodeId` is retained for backward
compatibility but ignored when the URI is present.

An automatic/omitted or partial security profile requires a
`SignAndEncrypt` endpoint and selects the strongest matching candidate.
Explicit `securityMode: "Sign"` opts into signed-but-unencrypted traffic.
Expand Down
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing to Opcilloscope
# Contributing to opcilloscope

Thank you for your interest in contributing to Opcilloscope!
Thank you for your interest in contributing to opcilloscope!

## Getting Started

Expand All @@ -16,11 +16,16 @@ Thank you for your interest in contributing to Opcilloscope!

### Prerequisites

- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [.NET SDK 10.0.109](https://dotnet.microsoft.com/download/dotnet/10.0),
matching the exact version pinned in `global.json`
- **Linux only:** ICU libraries (`sudo apt install libicu-dev` on Debian/Ubuntu, `sudo dnf install libicu-devel` on Fedora/RHEL)

### Building and Testing

If needed, install the pinned SDK with Microsoft's `dotnet-install.sh` using
`--version 10.0.109`; the repository intentionally does not roll forward to a
different feature band.

```bash
dotnet restore Opcilloscope.sln
dotnet build Opcilloscope.sln
Expand Down
28 changes: 25 additions & 3 deletions CommandLineOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ internal sealed record CommandLineOptions(
string? ConfigPath,
string? AutoConnectUrl,
bool AllowInsecureCertificates,
bool ShowHelp);
bool ShowHelp,
bool ShowVersion);

internal static class CommandLineParser
{
Expand All @@ -14,7 +15,23 @@ public static CommandLineOptions Parse(IReadOnlyList<string> args)
// when a shell alias appends stale/invalid arguments after --help.
if (args.Any(arg => arg is "--help" or "-h"))
{
return new CommandLineOptions(null, null, false, ShowHelp: true);
return new CommandLineOptions(
null,
null,
false,
ShowHelp: true,
ShowVersion: false);
}

// Version is also safe to answer without initializing a terminal.
if (args.Any(arg => arg is "--version" or "-V"))
{
return new CommandLineOptions(
null,
null,
false,
ShowHelp: false,
ShowVersion: true);
}

string? configPath = null;
Expand Down Expand Up @@ -62,7 +79,12 @@ public static CommandLineOptions Parse(IReadOnlyList<string> args)
}
}

return new CommandLineOptions(configPath, autoConnectUrl, allowInsecure, ShowHelp: false);
return new CommandLineOptions(
configPath,
autoConnectUrl,
allowInsecure,
ShowHelp: false,
ShowVersion: false);
}

private static string ReadOptionValue(IReadOnlyList<string> args, ref int index, string option)
Expand Down
36 changes: 35 additions & 1 deletion Configuration/ConfigurationService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using Opc.Ua;
using Opcilloscope.Configuration.Models;
using Opcilloscope.OpcUa;
using Opcilloscope.OpcUa.Models;
Expand Down Expand Up @@ -264,7 +265,8 @@ public OpcilloscopeConfig CaptureCurrentState(
ConfigMetadata? existingMetadata = null,
ConnectionCredentials? credentials = null,
ServerConfig? existingServer = null,
SubscriptionSettings? existingSettings = null)
SubscriptionSettings? existingSettings = null,
NamespaceTable? namespaceUris = null)
{
// Preserve fields that the UI does not currently surface (security mode/policy,
// sampling interval, queue size) so a load/save round-trip does not drop them.
Expand Down Expand Up @@ -299,6 +301,9 @@ public OpcilloscopeConfig CaptureCurrentState(
var monitoredNodes = monitoredVariables.Select(m => new MonitoredNodeConfig
{
NodeId = m.NodeId.ToString(),
NamespaceUri = m.NodeId.NamespaceIndex == 0
? null
: namespaceUris?.GetString(m.NodeId.NamespaceIndex),
DisplayName = m.DisplayName,
Enabled = true
}).ToList();
Expand All @@ -319,6 +324,7 @@ public OpcilloscopeConfig CaptureCurrentState(
.Select(n => new MonitoredNodeConfig
{
NodeId = n.NodeId,
NamespaceUri = n.NamespaceUri,
DisplayName = n.DisplayName,
Enabled = false
}));
Expand All @@ -337,6 +343,34 @@ public OpcilloscopeConfig CaptureCurrentState(
};
}

/// <summary>
/// Resolves a configured node against the active session namespace table.
/// Namespace URIs take precedence over numeric indexes because indexes are
/// allocated per session and may change after a server restart.
/// </summary>
internal static NodeId ResolveNodeId(
MonitoredNodeConfig configuredNode,
NamespaceTable namespaceUris)
{
ArgumentNullException.ThrowIfNull(configuredNode);
ArgumentNullException.ThrowIfNull(namespaceUris);

var nodeId = NodeId.Parse(configuredNode.NodeId);
if (string.IsNullOrWhiteSpace(configuredNode.NamespaceUri))
{
return nodeId;
}

var namespaceIndex = namespaceUris.GetIndex(configuredNode.NamespaceUri);
if (namespaceIndex < 0)
{
throw new InvalidDataException(
$"Server does not expose namespace URI '{configuredNode.NamespaceUri}'");
}

return new NodeId(nodeId.Identifier, (ushort)namespaceIndex);
}

/// <summary>
/// Resets the configuration service to a clean state (no file loaded).
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions Configuration/Models/OpcilloscopeConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,18 @@ public class SubscriptionSettings
/// </summary>
public class MonitoredNodeConfig
{
/// <summary>
/// The node identifier. Older configurations may include a numeric namespace
/// index; when <see cref="NamespaceUri"/> is present that index is ignored on
/// load and resolved against the active server session instead.
/// </summary>
public string NodeId { get; set; } = string.Empty;

/// <summary>
/// Stable namespace URI used to resolve the session-local namespace index.
/// Null preserves compatibility with v1.0.0 and older configurations.
/// </summary>
public string? NamespaceUri { get; set; }
public string DisplayName { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
}
Expand Down
Loading