diff --git a/.gitignore b/.gitignore index b8f5c9b..536d26f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ bin/ obj/ publish/ +publish-e2e/ # IDE .vs/ @@ -20,9 +21,6 @@ Thumbs.db # Test results TestResults/ -# Node modules (test server) -test-server/node_modules/ - # Logs *.log nul @@ -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 diff --git a/App/Dialogs/SaveConfigDialog.cs b/App/Dialogs/SaveConfigDialog.cs index 31a6925..b940b88 100644 --- a/App/Dialogs/SaveConfigDialog.cs +++ b/App/Dialogs/SaveConfigDialog.cs @@ -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 @@ -158,7 +158,7 @@ private void OnBrowseDirectory(object? sender, CommandEventArgs e) Title = "Browse for Save Location", AllowedTypes = new List { - 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 diff --git a/App/MainWindow.cs b/App/MainWindow.cs index a56da03..1cc9348 100644 --- a/App/MainWindow.cs +++ b/App/MainWindow.cs @@ -1,4 +1,3 @@ -using System.Reflection; using Terminal.Gui; using Opcilloscope.App.Keybindings; using Opcilloscope.App.Views; @@ -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); @@ -1385,28 +1384,6 @@ industrial automation data in real-time. TerminalUi.Query("About opcilloscope", about, "OK"); } - /// - /// Gets the application version for display. MinVer writes the full semver to - /// (AssemblyVersion is frozen at - /// MAJOR.0.0.0), so prefer that and strip any "+commitsha" build metadata. - /// - private static string GetDisplayVersion() - { - var assembly = Assembly.GetExecutingAssembly(); - var informational = assembly - .GetCustomAttribute()? - .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 /// @@ -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) { @@ -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 @@ -1934,6 +1915,19 @@ public void LoadConfigFromCommandLine(string configPath) }); } + /// + /// Connects to an endpoint supplied on the command line after the terminal + /// main loop has started. + /// + public void ConnectFromCommandLine(string endpoint) + { + TerminalUi.AddTimeout(TimeSpan.FromMilliseconds(100), () => + { + ConnectAsync(endpoint).FireAndForget(_logger); + return false; + }); + } + #endregion #region IKeybindingActions Implementation diff --git a/CLAUDE.md b/CLAUDE.md index ee5bc93..8f62e48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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" @@ -63,14 +63,16 @@ Usage: opcilloscope [options] [file] Options: -f, --config Load configuration file (.cfg, .opcilloscope, or .json) - -c, --connect Reserved; direct URL connection is not yet implemented + -c, --connect 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 @@ -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 } @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eb1f31..9213128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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 diff --git a/CommandLineOptions.cs b/CommandLineOptions.cs index acbcd67..3f4ff59 100644 --- a/CommandLineOptions.cs +++ b/CommandLineOptions.cs @@ -4,7 +4,8 @@ internal sealed record CommandLineOptions( string? ConfigPath, string? AutoConnectUrl, bool AllowInsecureCertificates, - bool ShowHelp); + bool ShowHelp, + bool ShowVersion); internal static class CommandLineParser { @@ -14,7 +15,23 @@ public static CommandLineOptions Parse(IReadOnlyList 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; @@ -62,7 +79,12 @@ public static CommandLineOptions Parse(IReadOnlyList args) } } - return new CommandLineOptions(configPath, autoConnectUrl, allowInsecure, ShowHelp: false); + return new CommandLineOptions( + configPath, + autoConnectUrl, + allowInsecure, + ShowHelp: false, + ShowVersion: false); } private static string ReadOptionValue(IReadOnlyList args, ref int index, string option) diff --git a/Configuration/ConfigurationService.cs b/Configuration/ConfigurationService.cs index e8d7840..0abb483 100644 --- a/Configuration/ConfigurationService.cs +++ b/Configuration/ConfigurationService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Opc.Ua; using Opcilloscope.Configuration.Models; using Opcilloscope.OpcUa; using Opcilloscope.OpcUa.Models; @@ -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. @@ -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(); @@ -319,6 +324,7 @@ public OpcilloscopeConfig CaptureCurrentState( .Select(n => new MonitoredNodeConfig { NodeId = n.NodeId, + NamespaceUri = n.NamespaceUri, DisplayName = n.DisplayName, Enabled = false })); @@ -337,6 +343,34 @@ public OpcilloscopeConfig CaptureCurrentState( }; } + /// + /// 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. + /// + 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); + } + /// /// Resets the configuration service to a clean state (no file loaded). /// diff --git a/Configuration/Models/OpcilloscopeConfig.cs b/Configuration/Models/OpcilloscopeConfig.cs index 8e9dea5..863a272 100644 --- a/Configuration/Models/OpcilloscopeConfig.cs +++ b/Configuration/Models/OpcilloscopeConfig.cs @@ -98,7 +98,18 @@ public class SubscriptionSettings /// public class MonitoredNodeConfig { + /// + /// The node identifier. Older configurations may include a numeric namespace + /// index; when is present that index is ignored on + /// load and resolved against the active server session instead. + /// public string NodeId { get; set; } = string.Empty; + + /// + /// Stable namespace URI used to resolve the session-local namespace index. + /// Null preserves compatibility with v1.0.0 and older configurations. + /// + public string? NamespaceUri { get; set; } public string DisplayName { get; set; } = string.Empty; public bool Enabled { get; set; } = true; } diff --git a/Program.cs b/Program.cs index c677370..ae9a89f 100644 --- a/Program.cs +++ b/Program.cs @@ -30,6 +30,12 @@ static int Main(string[] args) return 0; } + if (options.ShowVersion) + { + Console.WriteLine($"opcilloscope {VersionInfo.DisplayVersion}"); + return 0; + } + OpcUaClientWrapper.AllowInsecureByDefault = options.AllowInsecureCertificates; // Validate the config file path before initializing the terminal, so the error @@ -41,15 +47,6 @@ static int Main(string[] args) return 1; } - // Warn about unimplemented auto-connect before initializing the terminal; - // once the alternate screen buffer is active the message would be lost. - if (string.IsNullOrEmpty(options.ConfigPath) && !string.IsNullOrEmpty(options.AutoConnectUrl)) - { - Console.Error.WriteLine( - $"Warning: Auto-connect via command-line URL ('{options.AutoConnectUrl}') is not currently implemented. " + - "Please use a configuration file with an endpoint URL instead."); - } - app = Application.Create(); TerminalUi.App = app; app.Init(); @@ -62,6 +59,10 @@ static int Main(string[] args) { mainWindow.LoadConfigFromCommandLine(options.ConfigPath); } + else if (!string.IsNullOrEmpty(options.AutoConnectUrl)) + { + mainWindow.ConnectFromCommandLine(options.AutoConnectUrl); + } app.Run(mainWindow); } @@ -77,7 +78,6 @@ static int Main(string[] args) catch (Exception ex) { Console.Error.WriteLine($"Fatal error: {ex.Message}"); - Console.Error.WriteLine(ex.StackTrace); return 1; } finally @@ -93,23 +93,22 @@ static int Main(string[] args) private static void PrintUsage() { - Console.WriteLine("opcilloscope - Terminal-based OPC UA Client"); + Console.WriteLine("opcilloscope - terminal-based OPC UA client"); Console.WriteLine(); Console.WriteLine("Usage: opcilloscope [options] [file]"); Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" -f, --config Load configuration file (.cfg, .opcilloscope, or .json)"); - Console.WriteLine(" -c, --connect Reserved; direct URL connection is not yet implemented"); + Console.WriteLine(" -c, --connect Connect directly to an OPC UA endpoint"); Console.WriteLine(" --insecure Disable server certificate validation (development only)"); + Console.WriteLine(" -V, --version Show version information"); Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(); - Console.WriteLine("Note: Direct server connection via --connect or opc.tcp:// URLs is not yet"); - Console.WriteLine(" implemented. Please create a configuration file with the server URL."); - Console.WriteLine(); Console.WriteLine("Examples:"); Console.WriteLine(" opcilloscope Start with empty configuration"); Console.WriteLine(" opcilloscope production.cfg Load configuration file"); Console.WriteLine(" opcilloscope --config config.json Load configuration file"); + Console.WriteLine(" opcilloscope --connect opc.tcp://localhost:4840"); Console.WriteLine(); } } diff --git a/README.md b/README.md index 39d1b4f..63a0a19 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,11 @@ The scope view starts with a sliding **30-second window** (zoomable from 5 s to | `R` | Toggle CSV recording (selected monitored variables) | | `+` / `-` | Zoom in / out (scope) | | `Ctrl+O` / `Ctrl+S` | Open / save configuration | +| `Ctrl+Shift+S` | Save configuration as | | `Ctrl+R` | Toggle CSV recording | +| `[` / `]` | Widen / narrow the scope time window | +| Arrow keys | Pan scope; move the cursor left/right while paused | +| `Ctrl+Q` | Quit | | `?` | Help | ## Install @@ -128,6 +132,10 @@ validation for that run; it does not enable plaintext transport. Do not use this option in production. The connection log reports the trusted-certificate store path when validation fails. +Saved non-standard monitored nodes include their stable OPC UA namespace URI. +On reload, opcilloscope resolves that URI against the new session instead of +assuming the server reused a previous session's numeric namespace index. +
Uninstall @@ -180,9 +188,12 @@ deleting a shared custom install directory.
-## Quickstart (Developer) +## Quickstart (developer) -Requires [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). +Requires [.NET SDK 10.0.109](https://dotnet.microsoft.com/download/dotnet/10.0), +as pinned by [`global.json`](global.json). A different .NET 10 feature band will +not be selected automatically. Microsoft's install script can install the exact +version with `--version 10.0.109`. ```bash git clone https://github.com/SquareWaveSystems/opcilloscope.git @@ -209,10 +220,19 @@ See [docs/TESTING.md](docs/TESTING.md) for test layers and exact-artifact usage. **Built-in test server** (Counter, SineWave, RandomValue, writable nodes): ```bash +# Terminal 1: start the self-signed development server dotnet run --project Tests/Opcilloscope.TestServer # Starts at opc.tcp://localhost:4840/UA/OpcilloscopeTest + +# Terminal 2: connect while explicitly accepting its development certificate +dotnet run --project Opcilloscope.csproj -- --insecure --connect \ + opc.tcp://localhost:4840/UA/OpcilloscopeTest ``` +`--insecure` is appropriate only for this disposable local server. For a +long-lived server, trust its certificate using the store path reported in the +connection log. + **Public servers** (no setup required): | Server | Endpoint URL | @@ -220,6 +240,10 @@ dotnet run --project Tests/Opcilloscope.TestServer | OPC UA Server | `opc.tcp://opcuaserver.com:48010` | | Eclipse Milo | `opc.tcp://milo.digitalpetri.com:62541/milo` | +These endpoints are operated by third parties, so availability and certificates +can change. Enter the endpoint through **Connection → Connect**, then validate +or trust the certificate reported by the connection log. + **Docker** ([Microsoft OPC PLC](https://github.com/Azure-Samples/iot-edge-opc-plc)): ```bash docker run -p 50000:50000 mcr.microsoft.com/iotedge/opc-plc:latest \ diff --git a/Tests/Opcilloscope.E2ETests/ConnectionStartupTests.cs b/Tests/Opcilloscope.E2ETests/ConnectionStartupTests.cs new file mode 100644 index 0000000..c3dc00d --- /dev/null +++ b/Tests/Opcilloscope.E2ETests/ConnectionStartupTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +namespace Opcilloscope.E2ETests; + +/// +/// Published-binary regressions for command-line connection startup. +/// +[Collection("E2E")] +public sealed class ConnectionStartupTests +{ + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(15); + private readonly PublishedBinaryFixture _fixture; + + public ConnectionStartupTests(PublishedBinaryFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task ConfigFileConnection_RemainsConnectedAfterStartupBannerCompletes() + { + await RunWithTestServerAsync(async (server, tempRoot, environment) => + { + var configPath = Path.Combine(tempRoot, "issue-178.cfg"); + await File.WriteAllTextAsync(configPath, JsonSerializer.Serialize(new + { + version = "1.0", + server = new + { + endpointUrl = server.EndpointUrl, + securityMode = "None", + securityPolicy = "None", + }, + settings = new + { + publishingIntervalMs = 250, + samplingIntervalMs = 100, + }, + monitoredNodes = Array.Empty(), + })); + + using var application = new OpcilloscopeSession( + _fixture.BinaryPath, + ["--insecure", configPath], + rows: 35, + cols: 120, + extraEnvironment: environment); + + Assert.True( + application.WaitForText("Connected to", ConnectTimeout), + RenderedScreen(application)); + + await Task.Delay(TimeSpan.FromSeconds(5)); + var snapshot = application.Snapshot(); + var statusLine = snapshot.Split('\n') + .Reverse() + .FirstOrDefault(line => line.Contains("Connected", StringComparison.Ordinal)); + + Assert.NotNull(statusLine); + Assert.DoesNotContain("Not Connected", statusLine, StringComparison.Ordinal); + Assert.Contains("Connected", statusLine, StringComparison.Ordinal); + + QuitAndAssertCleanExit(application, snapshot); + }); + } + + [Fact] + public async Task ConnectOption_ConnectsDirectlyToEndpoint() + { + await RunWithTestServerAsync((server, _, environment) => + { + using var application = new OpcilloscopeSession( + _fixture.BinaryPath, + ["--insecure", "--connect", server.EndpointUrl], + rows: 35, + cols: 120, + extraEnvironment: environment); + + Assert.True( + application.WaitForText("Connected to", ConnectTimeout), + RenderedScreen(application)); + Assert.True( + application.WaitForText("● Connected", ConnectTimeout), + RenderedScreen(application)); + Assert.DoesNotContain("not currently implemented", application.Snapshot()); + + QuitAndAssertCleanExit(application, application.Snapshot()); + return Task.CompletedTask; + }); + } + + private static async Task RunWithTestServerAsync( + Func, Task> test) + { + var tempRoot = Path.Combine( + Path.GetTempPath(), + $"opcilloscope-connection-e2e-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + + try + { + var port = ReservePort(); + await using var server = new Opcilloscope.TestServer.TestServer( + Path.Combine(tempRoot, "server-pki")); + await server.StartAsync(port); + + var environment = new Dictionary + { + ["XDG_CONFIG_HOME"] = Path.Combine(tempRoot, "config"), + ["XDG_DATA_HOME"] = Path.Combine(tempRoot, "data"), + }; + await test(server, tempRoot, environment); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + + private static void QuitAndAssertCleanExit(OpcilloscopeSession application, string snapshot) + { + application.SendByte(0x11); + if (!application.WaitForExit(TimeSpan.FromSeconds(1))) + { + Assert.True( + application.WaitForText("Unsaved Changes", TimeSpan.FromSeconds(2)), + "Application neither exited nor displayed the unsaved-changes prompt.\n" + snapshot); + // MessageBox focuses its last button (Cancel) by default. Move left + // to Discard, then accept it. + application.Send("\x1b[D\r"); + } + + Assert.True( + application.WaitForExit(TimeSpan.FromSeconds(5)), + $"Application did not exit after Ctrl+Q.\n{snapshot}"); + Assert.Equal(0, application.ExitCode); + } + + private static int ReservePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static string RenderedScreen(OpcilloscopeSession application) => + "Rendered screen was:\n" + application.Snapshot(); +} diff --git a/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj index c1d0ecb..db12a58 100644 --- a/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj +++ b/Tests/Opcilloscope.E2ETests/Opcilloscope.E2ETests.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs index 2f7f7d2..e3892cf 100644 --- a/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs +++ b/Tests/Opcilloscope.E2ETests/OpcilloscopeSession.cs @@ -20,12 +20,18 @@ public OpcilloscopeSession( string binaryPath, IReadOnlyList? arguments = null, int rows = 30, - int cols = 100) + int cols = 100, + IReadOnlyDictionary? extraEnvironment = null) { Rows = rows; Cols = cols; _screen = new VtScreen(rows, cols); - _pty = Pty.Spawn(binaryPath, arguments ?? Array.Empty(), rows, cols); + _pty = Pty.Spawn( + binaryPath, + arguments ?? Array.Empty(), + rows, + cols, + extraEnvironment); _reader = new Thread(ReadLoop) { IsBackground = true, diff --git a/Tests/Opcilloscope.E2ETests/README.md b/Tests/Opcilloscope.E2ETests/README.md index e6b4142..cc998ec 100644 --- a/Tests/Opcilloscope.E2ETests/README.md +++ b/Tests/Opcilloscope.E2ETests/README.md @@ -1,4 +1,4 @@ -# Opcilloscope black-box E2E tests +# opcilloscope black-box E2E tests These Linux-only tests launch the published `opcilloscope` binary on a sized pseudo-terminal, answer Terminal.Gui's terminal-capability queries, reconstruct its VT/ANSI output, and assert diff --git a/Tests/Opcilloscope.E2ETests/StartupTests.cs b/Tests/Opcilloscope.E2ETests/StartupTests.cs index 3ec1dd8..9c3461f 100644 --- a/Tests/Opcilloscope.E2ETests/StartupTests.cs +++ b/Tests/Opcilloscope.E2ETests/StartupTests.cs @@ -69,6 +69,19 @@ public void ControlQ_ExitsCleanlyWithSuccess() Assert.Equal(0, application.ExitCode); } + [Fact] + public void Version_PrintsVersionWithoutOpeningTheTui() + { + using var application = new OpcilloscopeSession(_fixture.BinaryPath, ["--version"]); + + Assert.True( + application.WaitForText("opcilloscope ", TimeSpan.FromSeconds(5)), + RenderedScreen(application)); + Assert.True(application.WaitForExit(TimeSpan.FromSeconds(5))); + Assert.Equal(0, application.ExitCode); + Assert.DoesNotContain("Address Space", application.Snapshot()); + } + private static string RenderedScreen(OpcilloscopeSession application) => "Rendered screen was:\n" + application.Snapshot(); } diff --git a/Tests/Opcilloscope.Tests/CommandLineParserTests.cs b/Tests/Opcilloscope.Tests/CommandLineParserTests.cs index d0e31aa..8f685fc 100644 --- a/Tests/Opcilloscope.Tests/CommandLineParserTests.cs +++ b/Tests/Opcilloscope.Tests/CommandLineParserTests.cs @@ -55,6 +55,20 @@ public void Parse_Help_ShortCircuitsTrailingInvalidArguments(string help) var options = CommandLineParser.Parse([help, "--wat", "--config"]); Assert.True(options.ShowHelp); + Assert.False(options.ShowVersion); + Assert.Null(options.ConfigPath); + Assert.Null(options.AutoConnectUrl); + } + + [Theory] + [InlineData("--version")] + [InlineData("-V")] + public void Parse_Version_ShortCircuitsTrailingInvalidArguments(string version) + { + var options = CommandLineParser.Parse([version, "--wat", "--config"]); + + Assert.True(options.ShowVersion); + Assert.False(options.ShowHelp); Assert.Null(options.ConfigPath); Assert.Null(options.AutoConnectUrl); } @@ -69,5 +83,6 @@ public void Parse_AllPositiveOptions_ArePreserved() Assert.Equal("opc.tcp://server:4840", options.AutoConnectUrl); Assert.True(options.AllowInsecureCertificates); Assert.False(options.ShowHelp); + Assert.False(options.ShowVersion); } } diff --git a/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs b/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs index 7cb7f32..e2594d2 100644 --- a/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs +++ b/Tests/Opcilloscope.Tests/Configuration/ConfigurationServiceTests.cs @@ -414,6 +414,76 @@ public void CaptureCurrentState_WithNullEndpoint_UsesEmptyString() Assert.Equal(string.Empty, config.Server.EndpointUrl); } + [Fact] + public void CaptureCurrentState_WithNamespaceTable_PersistsStableNamespaceUri() + { + var namespaceUris = new NamespaceTable(); + var namespaceIndex = namespaceUris.GetIndexOrAppend("urn:example:machine"); + var monitoredVariables = new List + { + new() + { + NodeId = new NodeId("Counter", (ushort)namespaceIndex), + DisplayName = "Counter" + } + }; + + var config = _service.CaptureCurrentState( + "opc.tcp://localhost:4840", + 250, + monitoredVariables, + namespaceUris: namespaceUris); + + var node = Assert.Single(config.MonitoredNodes); + Assert.Equal($"ns={namespaceIndex};s=Counter", node.NodeId); + Assert.Equal("urn:example:machine", node.NamespaceUri); + } + + [Fact] + public void ResolveNodeId_WithNamespaceUri_UsesCurrentSessionIndex() + { + var namespaceUris = new NamespaceTable(); + namespaceUris.GetIndexOrAppend("urn:example:other"); + var currentIndex = namespaceUris.GetIndexOrAppend("urn:example:machine"); + var configuredNode = new MonitoredNodeConfig + { + NodeId = "ns=2;s=Counter", + NamespaceUri = "urn:example:machine", + DisplayName = "Counter" + }; + + var resolved = ConfigurationService.ResolveNodeId(configuredNode, namespaceUris); + + Assert.Equal((ushort)currentIndex, resolved.NamespaceIndex); + Assert.Equal("Counter", resolved.Identifier); + } + + [Fact] + public void ResolveNodeId_WithoutNamespaceUri_PreservesLegacyNumericIndex() + { + var resolved = ConfigurationService.ResolveNodeId( + new MonitoredNodeConfig { NodeId = "ns=7;s=Counter" }, + new NamespaceTable()); + + Assert.Equal((ushort)7, resolved.NamespaceIndex); + Assert.Equal("Counter", resolved.Identifier); + } + + [Fact] + public void ResolveNodeId_WithMissingNamespaceUri_ThrowsClearError() + { + var error = Assert.Throws(() => + ConfigurationService.ResolveNodeId( + new MonitoredNodeConfig + { + NodeId = "ns=2;s=Counter", + NamespaceUri = "urn:example:missing" + }, + new NamespaceTable())); + + Assert.Contains("urn:example:missing", error.Message); + } + [Fact] public void GetDefaultConfigDirectory_ReturnsValidPath() { @@ -742,7 +812,13 @@ public async Task LoadThenCaptureCurrentState_PreservesDisabledNodes() config.MonitoredNodes = new List { new() { NodeId = "ns=2;s=Counter", DisplayName = "Counter", Enabled = true }, - new() { NodeId = "ns=2;s=Spare", DisplayName = "Spare", Enabled = false } + new() + { + NodeId = "ns=2;s=Spare", + NamespaceUri = "urn:example:machine", + DisplayName = "Spare", + Enabled = false + } }; var filePath = Path.Combine(_tempDir, "disabled.cfg"); await _service.SaveAsync(config, filePath); @@ -765,6 +841,7 @@ public async Task LoadThenCaptureCurrentState_PreservesDisabledNodes() var disabled = captured.MonitoredNodes.Single(n => n.NodeId == "ns=2;s=Spare"); Assert.False(disabled.Enabled); Assert.Equal("Spare", disabled.DisplayName); + Assert.Equal("urn:example:machine", disabled.NamespaceUri); } [Fact] diff --git a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs index 6dd27f8..0061334 100644 --- a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs +++ b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs @@ -262,24 +262,22 @@ 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. + /// Runs an action with the given culture as the current async-flow culture, + /// restoring it afterwards. Do not mutate DefaultThreadCurrentCulture here: + /// it is process-global and races with parallel test workers. /// 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; } } diff --git a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs index 34c6f48..12cc954 100644 --- a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs +++ b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs @@ -537,26 +537,23 @@ public void StopRecording_FlushesQueuedRecordsBeforeClosing() } /// - /// 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. + /// Runs an action under a hostile culture. CurrentCulture flows to the + /// background writer task via ExecutionContext. Avoid changing + /// DefaultThreadCurrentCulture because it is process-global and would race + /// with parallel test workers. /// 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; } } diff --git a/Utilities/VersionInfo.cs b/Utilities/VersionInfo.cs new file mode 100644 index 0000000..0f9fd14 --- /dev/null +++ b/Utilities/VersionInfo.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace Opcilloscope.Utilities; + +/// +/// Provides the user-facing application version written by MinVer. +/// +internal static class VersionInfo +{ + public static string DisplayVersion { get; } = GetDisplayVersion(); + + private static string GetDisplayVersion() + { + var assembly = typeof(VersionInfo).Assembly; + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + + if (!string.IsNullOrEmpty(informational)) + { + var metadataIndex = informational.IndexOf('+'); + return metadataIndex >= 0 ? informational[..metadataIndex] : informational; + } + + return assembly.GetName().Version?.ToString(3) ?? "0.0.0"; + } +} diff --git a/docs/PROMOTIONAL-MEDIA.md b/docs/PROMOTIONAL-MEDIA.md new file mode 100644 index 0000000..d0eb427 --- /dev/null +++ b/docs/PROMOTIONAL-MEDIA.md @@ -0,0 +1,26 @@ +# Promotional media + +The assets in `docs/media/` are reproducible recordings of the real opcilloscope +binary connected to the repository's local OPC UA test server. No public server, +mouse input, or manual timing is involved. + +Run the capture from a Hyprland desktop session: + +```bash +./scripts/capture-media.sh +``` + +The script creates three clips in both formats: + +- `ui-live-monitor` — the main interface receiving four live signals +- `scope-sine` — a focused, high-resolution sine-wave scope +- `scope-multi-wave` — sine, triangle, square, and sawtooth signals together + +GIF files are intended for social platforms. Animated WebP files contain the +same captures at a substantially smaller size and are preferred for the README. +The script uses an isolated tmux session and temporary configuration, starts and +stops the demo server itself, drives only normal terminal keyboard input, and +replaces existing generated assets. No capture-only behavior is compiled into +the production application. + +Requirements: .NET SDK 10.0.109, Hyprland, foot, tmux, grim, jq, and ffmpeg. diff --git a/docs/UAT-CHECKLIST.md b/docs/UAT-CHECKLIST.md new file mode 100644 index 0000000..cc76c73 --- /dev/null +++ b/docs/UAT-CHECKLIST.md @@ -0,0 +1,124 @@ +# opcilloscope v1.x — manual regression checklist + +Use this for post-release maintenance and before future v1.x tags. The v1.0.0 +tag has already shipped; unchecked boxes describe manual coverage still to run, +not the state of the published release. Items marked **[v1]** changed during the +original v1 fix sweep and deserve extra attention. + +## 0. Setup + +- [ ] **Test the actual release artifact, not just `dotnet run`.** Publish the self-contained + single-file binary and run *that*: + `dotnet publish Opcilloscope.csproj -c Release -r linux-x64 -o ./publish` + then `./publish/opcilloscope`. + **[v1]** Confirm it launches with no `$schema` / startup error (trimming is now disabled). +- [ ] Start the in-process test server in another terminal: + `dotnet run --project Tests/Opcilloscope.TestServer` + → it prints `Endpoint: opc.tcp://localhost:4840/UA/OpcilloscopeTest`. + (Test nodes live under **Simulation** — Counter, SineWave, SquareWave, etc. — and **StaticData**.) +- [ ] Have a terminal at least ~100×30 for the TUI to lay out comfortably. + +## 1. CLI / startup + +- [ ] **[v1]** `opcilloscope --help` prints usage and exits 0 **without opening the TUI / a terminal** + (works over SSH / non-interactive too). Same for `-h`. +- [ ] `opcilloscope --version` prints the application version and exits 0 without opening the TUI. +- [ ] `opcilloscope` (no args) starts with an empty configuration. +- [ ] `opcilloscope .cfg` and `opcilloscope --config .json` load that config on startup. +- [ ] `opcilloscope --connect opc.tcp://…` connects directly to that endpoint. +- [ ] `opcilloscope /does/not/exist.cfg` prints `Error: Configuration file not found:` and exits 1. +- [ ] **[v1]** `opcilloscope --insecure` is accepted (no "unknown argument"); app starts normally. +- [ ] `Ctrl+Q` quits cleanly — terminal is fully restored (no leftover colors/alt-screen, cursor visible). + +## 2. Connection & security + +- [ ] Connect (menu / Connect dialog) to `opc.tcp://localhost:4840/UA/OpcilloscopeTest`, **Anonymous** → + status shows Connected; address space populates. +- [ ] Connect with **username/password** (the test server accepts credentialed sessions) → Connected. +- [ ] **[v1] Secure-by-default:** launch *without* `--insecure` and connect **with credentials** → + connection is **refused** with a clear log message about an untrusted certificate / pointing at `--insecure`. +- [ ] **[v1]** Relaunch *with* `--insecure`, connect with the same credentials → now **succeeds**. +- [ ] Connect to a bad endpoint (e.g. `opc.tcp://localhost:4999`) → error appears in the log pane, + app does **not** crash. +- [ ] **[v1]** Disconnect while connected → UI stays **responsive** (no multi-second freeze), status returns to Disconnected. + +## 3. Address space browsing + +- [ ] Tree shows the server root; expanding a folder lazily loads children. +- [ ] Select a node → **Node Details** pane shows its attributes (NodeId, DataType, Value, etc.). +- [ ] `F5` in the address space refreshes the tree. +- [ ] `Tab` cycles focus between panes; focus indicator moves correctly. + +## 4. Subscribe / monitor + +- [ ] Select `Simulation/Counter`, press `Enter` → it appears in **Monitored Variables** and the value **increments every second**. +- [ ] Subscribe to `SineWave`, `SquareWave`, `TriangleWave`, `SawtoothWave` → values update live and look correct (sine oscillates, square toggles 0/100, etc.). +- [ ] Select a monitored variable, press `Delete` → it is removed and stops updating. +- [ ] Subscribing to an invalid/again-existing node is handled gracefully (no crash; sensible log). + +## 5. Write values (`W`) + +- [ ] `W` on `Simulation/WritableString` (from the address space **or** monitored pane) → write dialog opens; writing a new string updates the value. +- [ ] `W` on `Simulation/WritableNumber` (Int32) → write an integer; value updates. +- [ ] `W` on `Simulation/ToggleBoolean` → write `true`/`false`; value updates. +- [ ] `W` on `Simulation/Counter` (read-only) → graceful "not writable" / access-denied message, no crash. +- [ ] **[v1] Culture-correct numeric write:** write `3.14` to a Double (e.g. `SineFrequency`) → stored as **3.14**. + Confirm a comma form like `3,14` is **not** silently turned into `314` (it should be rejected or parsed as 3.14, never 314). + +## 6. Scope view (`S`) + +- [ ] In Monitored Variables, `Space` to select 2–5 variables, then `s`/`S` → Scope opens with one coloured trace per signal (Green/Cyan/Yellow/Magenta/White). +- [ ] Traces scroll in real time; time axis advances. +- [ ] `Space` pauses/resumes; `+`/`-` zoom the Y scale; `r` resets to auto-scale. +- [ ] `[` widens / `]` narrows the time window; `↑`/`↓` pan; when **paused**, `←`/`→` move the cursor. +- [ ] Selecting >5 signals is capped at 5 (no crash); opening Scope with a single signal works. +- [ ] Close Scope and re-open several times → no slowdown or leftover artifacts **[v1]** (dialogs are now disposed; watch for any creeping lag over ~5 opens). + +## 7. CSV recording (`R` / `Ctrl+R`) + +- [ ] Start recording (`r`/`R` in Monitored Variables, or `Ctrl+R`) → save dialog; pick a path; status shows Recording. +- [ ] Let it run ~15 s on Counter + a wave, then stop. Open the CSV: + - [ ] Header is exactly `Timestamp,DisplayName,NodeId,Value,Status`. + - [ ] Timestamps are ISO-8601 with milliseconds. + - [ ] **[v1]** Values match what was displayed at each sample — **no duplicated or skipped rows** under load (the recorded Counter sequence should be monotonic with no repeats/gaps). + - [ ] **[v1]** The **last** samples right before you stopped are present (the trailing queue is flushed on stop, not dropped). +- [ ] Start a *second* recording to a new file → it contains only new data, **no leftover rows** from the first session **[v1]**. + +## 8. Auto-reconnect **[v1]** + +- [ ] While connected and monitoring live values, **kill the test server** (Ctrl+C in its terminal). + - [ ] App detects the drop: status shows Reconnecting (retries with backoff 1s, 2s, 4s, 8s), app does not crash. +- [ ] **Restart** the test server → the client **reconnects automatically** and values resume updating + (you should not have to reconnect manually). This is the core v1 reconnect fix. + +## 9. Configuration save / load + +- [ ] With several nodes subscribed and settings set, `Ctrl+S` (Save) / `Ctrl+Shift+S` (Save As) → file written. +- [ ] Quit, relaunch with that config (`opcilloscope `) → server, monitored nodes, and settings are restored. +- [ ] `Ctrl+O` opens a different config at runtime and applies it. +- [ ] **[v1] Round-trip fidelity:** in a saved config, confirm `securityMode`, `securityPolicy`, + `samplingIntervalMs`, and `queueSize` survive a load→save→reload (they are no longer dropped). +- [ ] **[v1]** Saving over an existing config never leaves a half-written/corrupt file (atomic write); a leftover `*.tmp` should not remain. + +## 10. Themes, help & docs + +- [ ] View menu → toggle **Dark/Light**: every pane *and* open dialog restyles correctly; no unreadable text. +- [ ] `?` shows quick help; full Help dialog opens. +- [ ] **[v1]** Help shows each shortcut **once** (no duplicate `R`/`S`/`W` rows) and lists `F5`, the scope pan keys (`[` `]`, arrows), etc. +- [ ] **[v1] Trend is gone:** there is **no** `T` shortcut and **no** Trend Plot dialog/menu anywhere; pressing `T` does nothing. (Multi-signal Scope is the only plot.) +- [ ] README / CLAUDE keyboard tables match what the app actually does (spot-check 3–4 shortcuts). + +## 11. General robustness + +- [ ] Resize the terminal while running → layout reflows without corruption. +- [ ] Leave the app monitoring for several minutes → no runaway memory, no UI thread stalls. +- [ ] Trigger a few error paths (bad node, disconnect mid-browse, write to read-only) → all handled in the log, never a crash. + +## 12. Release-artifact sanity + +- [ ] **[v1]** Built binary reports the real version (not `0.0.0-alpha`) — the MinVer `v` tag prefix fix. + Check via the About/Help screen or release artifact name. +- [ ] The release workflow produces `opcilloscope-.tar.gz` / `.zip` artifacts and the publish smoke step passes. + +--- +**Sign-off:** _________________________ **Date:** ____________ **Build/commit:** ____________ diff --git a/docs/media/scope-multi-wave.gif b/docs/media/scope-multi-wave.gif new file mode 100644 index 0000000..db06237 Binary files /dev/null and b/docs/media/scope-multi-wave.gif differ diff --git a/docs/media/scope-multi-wave.webp b/docs/media/scope-multi-wave.webp new file mode 100644 index 0000000..82c7c7f Binary files /dev/null and b/docs/media/scope-multi-wave.webp differ diff --git a/docs/media/scope-sine.gif b/docs/media/scope-sine.gif new file mode 100644 index 0000000..4ece6fe Binary files /dev/null and b/docs/media/scope-sine.gif differ diff --git a/docs/media/scope-sine.webp b/docs/media/scope-sine.webp new file mode 100644 index 0000000..42baa70 Binary files /dev/null and b/docs/media/scope-sine.webp differ diff --git a/docs/media/ui-live-monitor.gif b/docs/media/ui-live-monitor.gif new file mode 100644 index 0000000..335478a Binary files /dev/null and b/docs/media/ui-live-monitor.gif differ diff --git a/docs/media/ui-live-monitor.webp b/docs/media/ui-live-monitor.webp new file mode 100644 index 0000000..fa5b357 Binary files /dev/null and b/docs/media/ui-live-monitor.webp differ diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 0000000..f3bf2ae --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,63 @@ +# opcilloscope v1.0.0 + +The first stable release of opcilloscope: a lightweight, keyboard-driven OPC UA +client for Linux, macOS, and Windows terminals. + +## Highlights + +- Browse OPC UA address spaces lazily and inspect node attributes. +- Subscribe to live variables using OPC UA monitored-item notifications. +- Plot up to five signals in the real-time Scope view. +- Record selected signals to CSV without blocking the UI. +- Save and restore connection, security, subscription, and monitored-node + configuration. +- Choose dark, light, or terminal-native colour themes. + +## Reliability and security + +- Automatic reconnect restores subscriptions after an interrupted session. +- Server certificates are rejected unless trusted; `--insecure` is an explicit + development-only override. +- Automatic security selection requires `SignAndEncrypt`; plaintext requires an + explicit anonymous `securityMode: "None"` configuration. +- Usernames may be stored in configuration, but passwords are prompted at runtime + and are never persisted. +- Configuration writes are atomic, and CSV shutdown drains accepted records before + closing the file. + +## Install + +Linux or macOS: + +```bash +curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/install.sh | bash +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/install.ps1 | iex +``` + +Release archives are available for Linux, macOS, and Windows on x64 and ARM64. +They are self-contained and do not require a separate .NET installation. + +## Verification + +Every archive has an entry in `SHA256SUMS` and contains the project license plus +notices for bundled third-party components. The release pipeline ran the complete +cross-platform test suite, native command-line smoke tests, and the Linux +published-binary TUI suite through a real pseudo-terminal. + +## Notes + +- macOS binaries are unsigned. The installer avoids browser quarantine; browser + downloads may require `xattr -d com.apple.quarantine ` after extraction. +- Connect through the in-app Connection dialog or a saved configuration file. + Direct `--connect` startup is not implemented in the v1.0.0 artifact. +- Use `--insecure` only with disposable development servers. It disables + certificate validation for that run; it does not itself select plaintext + transport. + +See the [README](https://github.com/SquareWaveSystems/opcilloscope/blob/v1.0.0/README.md) +for usage, shortcuts, security profiles, and test-server examples. diff --git a/install.ps1 b/install.ps1 index 98b5cb4..74cd2cb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,4 @@ -# Opcilloscope installer for Windows +# opcilloscope installer for Windows # Usage: irm https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/install.ps1 | iex $ErrorActionPreference = "Stop" @@ -111,8 +111,8 @@ function Install-LicenseMaterial { function Install-Opcilloscope { Write-Host "" Write-Host " +===================================+" -ForegroundColor Cyan - Write-Host " | Opcilloscope Installer |" -ForegroundColor Cyan - Write-Host " | Terminal OPC UA Client |" -ForegroundColor Cyan + Write-Host " | opcilloscope installer |" -ForegroundColor Cyan + Write-Host " | terminal OPC UA client |" -ForegroundColor Cyan Write-Host " +===================================+" -ForegroundColor Cyan Write-Host "" @@ -207,7 +207,7 @@ function Install-Opcilloscope { Write-Warn "Custom install directory is not in PATH; PATH was left unchanged." } - Write-Info "Opcilloscope $version installed successfully!" + Write-Info "opcilloscope $version installed successfully!" Write-Host "" Write-Host "Run 'opcilloscope' to start the application." -ForegroundColor White Write-Host "(You may need to restart other terminals for PATH changes to take effect)" -ForegroundColor Gray diff --git a/install.sh b/install.sh index b4ec651..af9f24d 100644 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -# Opcilloscope installer for Linux and macOS +# opcilloscope installer for Linux and macOS # Usage: curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/install.sh | bash REPO="SquareWaveSystems/opcilloscope" @@ -160,7 +160,7 @@ install_opcilloscope() { error "Installed executable failed its command-line smoke test" fi - info "Opcilloscope ${version} installed successfully!" + info "opcilloscope ${version} installed successfully!" echo "" if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then @@ -182,8 +182,8 @@ install_opcilloscope() { main() { echo "" echo " ╔═══════════════════════════════════╗" - echo " ║ Opcilloscope Installer ║" - echo " ║ Terminal OPC UA Client ║" + echo " ║ opcilloscope installer ║" + echo " ║ terminal OPC UA client ║" echo " ╚═══════════════════════════════════╝" echo "" diff --git a/scripts/capture-media.sh b/scripts/capture-media.sh new file mode 100755 index 0000000..0f0000b --- /dev/null +++ b/scripts/capture-media.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reproducible promotional captures for opcilloscope. +# Requires Linux/Hyprland: dotnet, foot, tmux, grim, jq, ffmpeg. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_dir="${1:-$repo_root/docs/media}" +work_dir="$(mktemp -d -t opcilloscope-capture.XXXXXX)" +server_session="opcilloscope-media-server-$$" +app_session="opcilloscope-media-app-$$" +app_id="opcilloscope-media-$$" +fps=10 +port=$((14840 + ($$ % 1000))) + +cleanup() { + tmux kill-session -t "$app_session" 2>/dev/null || true + tmux kill-session -t "$server_session" 2>/dev/null || true + rm -rf "$work_dir" +} +trap cleanup EXIT INT TERM + +for command in dotnet foot tmux grim jq ffmpeg hyprctl; do + command -v "$command" >/dev/null || { + echo "Missing required command: $command" >&2 + exit 1 + } +done + +if [[ -z "${WAYLAND_DISPLAY:-}" ]] || [[ "${XDG_CURRENT_DESKTOP:-}" != *Hyprland* ]]; then + echo "Run this script from a Hyprland desktop session." >&2 + exit 1 +fi + +mkdir -p "$output_dir" + +if [[ -n "${OPCILLOSCOPE_CAPTURE_BINARY:-}" ]]; then + binary="$OPCILLOSCOPE_CAPTURE_BINARY" +else + binary="$work_dir/publish/opcilloscope" + dotnet publish "$repo_root/Opcilloscope.csproj" -c Release -r linux-x64 \ + -o "$(dirname "$binary")" -p:DebugType=none +fi + +if [[ -z "${CAPTURE_ONLY:-}" ]]; then + for scene in ui-live-monitor scope-sine scope-multi-wave; do + OPCILLOSCOPE_CAPTURE_BINARY="$binary" CAPTURE_ONLY="$scene" "$0" "$output_dir" + done + exit 0 +fi + +config="$work_dir/promotional-demo.cfg" +jq --arg endpoint "opc.tcp://localhost:$port/UA/OpcilloscopeTest" \ + '.server.endpointUrl = $endpoint' \ + "$repo_root/scripts/promotional-demo.cfg" >"$config" + +server_binary="$repo_root/Tests/Opcilloscope.TestServer/bin/Release/net10.0/Opcilloscope.TestServer" +if [[ ! -x "$server_binary" ]]; then + dotnet build "$repo_root/Tests/Opcilloscope.TestServer/Opcilloscope.TestServer.csproj" \ + -c Release +fi + +tmux new-session -d -s "$server_session" -x 120 -y 40 \ + "exec '$server_binary' --port '$port'" + +for _ in {1..150}; do + if tmux capture-pane -pt "$server_session" 2>/dev/null | grep -q "Server started successfully"; then + break + fi + tmux has-session -t "$server_session" 2>/dev/null || { + echo "Demo server exited during startup." >&2 + exit 1 + } + sleep 0.1 +done +tmux capture-pane -pt "$server_session" 2>/dev/null | grep -q "Server started successfully" || { + echo "Demo server did not start:" >&2 + tmux capture-pane -pt "$server_session" >&2 + exit 1 +} + +window_geometry() { + hyprctl clients -j | jq -r --arg app_id "$app_id" ' + first(.[] | select(.class == $app_id)) | + "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"' +} + +wait_for_window() { + for _ in {1..100}; do + geometry="$(window_geometry 2>/dev/null || true)" + if [[ -n "$geometry" && "$geometry" != "null" ]]; then + printf '%s\n' "$geometry" + return 0 + fi + sleep 0.1 + done + echo "Capture terminal did not appear." >&2 + return 1 +} + +start_app() { + tmux kill-session -t "$app_session" 2>/dev/null || true + tmux new-session -d -s "$app_session" -x 116 -y 36 \ + "TERM=xterm-256color exec '$binary' '$config'" + foot -a "$app_id" -T "opcilloscope — live OPC UA signals" -W 116x36 \ + -f "JetBrainsMono Nerd Font:size=13" tmux attach-session -t "$app_session" & + foot_pid=$! + geometry="$(wait_for_window)" + + for _ in {1..150}; do + if tmux capture-pane -pt "$app_session" | grep -q "Configuration loaded: 4 nodes"; then + return 0 + fi + tmux has-session -t "$app_session" 2>/dev/null || { + echo "opcilloscope exited while loading the capture scene." >&2 + exit 1 + } + sleep 0.1 + done + + echo "opcilloscope did not finish loading the capture scene:" >&2 + tmux capture-pane -pt "$app_session" >&2 + exit 1 +} + +open_scope() { + local scene="$1" + + # Terminal.Gui may tab through the frame and its child control separately. + # Keep advancing until the context-aware status bar proves that Monitored + # Variables owns focus, instead of relying on a fixed number of Tab presses. + for _ in {1..8}; do + tmux send-keys -t "$app_session" Tab + sleep 0.2 + if tmux capture-pane -pt "$app_session" | tail -n 2 | grep -q "Unsub"; then + break + fi + done + tmux capture-pane -pt "$app_session" | tail -n 2 | grep -q "Unsub" || { + echo "Could not focus Monitored Variables for capture scene '$scene':" >&2 + tmux capture-pane -pt "$app_session" >&2 + exit 1 + } + + tmux send-keys -t "$app_session" Home Space + + if [[ "$scene" == "scope-multi-wave" ]]; then + for _ in {1..3}; do + tmux send-keys -t "$app_session" Down Space + done + fi + + tmux send-keys -t "$app_session" s + + for _ in {1..50}; do + if tmux capture-pane -pt "$app_session" | grep -q "SCOPE"; then + return 0 + fi + sleep 0.1 + done + + echo "Scope did not open for capture scene '$scene':" >&2 + tmux capture-pane -pt "$app_session" >&2 + exit 1 +} + +stop_app() { + tmux send-keys -t "$app_session" C-q 2>/dev/null || true + for _ in {1..30}; do + kill -0 "$foot_pid" 2>/dev/null || return 0 + sleep 0.1 + done + tmux kill-session -t "$app_session" 2>/dev/null || true +} + +record_frames() { + local name="$1" + local seconds="$2" + local frame_dir="$work_dir/$name" + local total=$((seconds * fps)) + mkdir -p "$frame_dir" + + for ((frame = 0; frame < total; frame++)); do + grim -g "$geometry" "$frame_dir/$(printf '%04d' "$frame").png" + sleep 0.1 + done + + ffmpeg -hide_banner -loglevel error -y -framerate "$fps" \ + -i "$frame_dir/%04d.png" -vf \ + "fps=$fps,scale=1200:-2:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128:stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" \ + -loop 0 "$output_dir/$name.gif" + + ffmpeg -hide_banner -loglevel error -y -framerate "$fps" \ + -i "$frame_dir/%04d.png" -vf "fps=$fps,scale=1200:-2:flags=lanczos" \ + -c:v libwebp_anim -lossless 0 -quality 82 -compression_level 6 -loop 0 \ + "$output_dir/$name.webp" +} + +case "$CAPTURE_ONLY" in + ui-live-monitor) + echo "Capturing live monitor overview..." + start_app + record_frames "ui-live-monitor" 6 + ;; + scope-sine|scope-multi-wave) + echo "Capturing ${CAPTURE_ONLY#scope-} scope..." + start_app + open_scope "$CAPTURE_ONLY" + sleep 2 + record_frames "$CAPTURE_ONLY" 8 + ;; + *) + echo "Unknown capture scene: $CAPTURE_ONLY" >&2 + exit 1 + ;; +esac + +echo "Created:" +find "$output_dir" -maxdepth 1 -type f \( -name '*.gif' -o -name '*.webp' \) \ + -printf ' %f (%k KiB)\n' | sort diff --git a/scripts/promotional-demo.cfg b/scripts/promotional-demo.cfg new file mode 100644 index 0000000..2fc79e6 --- /dev/null +++ b/scripts/promotional-demo.cfg @@ -0,0 +1,24 @@ +{ + "version": "1.0", + "server": { + "endpointUrl": "opc.tcp://localhost:4840/UA/OpcilloscopeTest", + "securityMode": "None", + "securityPolicy": "None", + "authentication": { "type": "Anonymous" } + }, + "settings": { + "publishingIntervalMs": 100, + "samplingIntervalMs": 100, + "queueSize": 10 + }, + "monitoredNodes": [ + { "nodeId": "s=SineWave", "namespaceUri": "urn:opcilloscope:testserver", "displayName": "Sine Wave", "enabled": true }, + { "nodeId": "s=TriangleWave", "namespaceUri": "urn:opcilloscope:testserver", "displayName": "Triangle Wave", "enabled": true }, + { "nodeId": "s=SquareWave", "namespaceUri": "urn:opcilloscope:testserver", "displayName": "Square Wave", "enabled": true }, + { "nodeId": "s=SawtoothWave", "namespaceUri": "urn:opcilloscope:testserver", "displayName": "Sawtooth Wave", "enabled": true } + ], + "metadata": { + "name": "Live signal lab", + "description": "Deterministic promotional capture" + } +} diff --git a/uninstall.ps1 b/uninstall.ps1 index 6163c3f..200bbb2 100644 --- a/uninstall.ps1 +++ b/uninstall.ps1 @@ -1,4 +1,4 @@ -# Opcilloscope uninstaller for Windows +# opcilloscope uninstaller for Windows # Usage: irm https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.ps1 | iex $ErrorActionPreference = "Stop" @@ -59,8 +59,8 @@ function Remove-DirectoryIfEmpty { function Uninstall-Opcilloscope { Write-Host "" Write-Host " +===================================+" -ForegroundColor Cyan - Write-Host " | Opcilloscope Uninstaller |" -ForegroundColor Cyan - Write-Host " | Terminal OPC UA Client |" -ForegroundColor Cyan + Write-Host " | opcilloscope uninstaller |" -ForegroundColor Cyan + Write-Host " | terminal OPC UA client |" -ForegroundColor Cyan Write-Host " +===================================+" -ForegroundColor Cyan Write-Host "" @@ -125,10 +125,10 @@ function Uninstall-Opcilloscope { Write-Host "" if ($removedSomething) { - Write-Info "Opcilloscope has been uninstalled." + Write-Info "opcilloscope has been uninstalled." Write-Host "(Restart open terminals to pick up PATH changes.)" -ForegroundColor Gray } else { - Write-Warn "Opcilloscope does not appear to be installed at $InstallDir." + Write-Warn "opcilloscope does not appear to be installed at $InstallDir." Write-Host "" Write-Host 'If you installed to a custom directory, set $env:OPCILLOSCOPE_INSTALL_DIR first:' -ForegroundColor White Write-Host ' $env:OPCILLOSCOPE_INSTALL_DIR = "C:\your\path"; .\uninstall.ps1' -ForegroundColor White diff --git a/uninstall.sh b/uninstall.sh index f3acc70..ca1d94d 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -# Opcilloscope uninstaller for Linux and macOS +# opcilloscope uninstaller for Linux and macOS # Usage: curl -fsSL https://raw.githubusercontent.com/SquareWaveSystems/opcilloscope/main/uninstall.sh | bash INSTALL_DIR="${OPCILLOSCOPE_INSTALL_DIR:-$HOME/.local/bin}" @@ -45,8 +45,8 @@ confirm_removal() { uninstall_opcilloscope() { echo "" echo " ╔═══════════════════════════════════╗" - echo " ║ Opcilloscope Uninstaller ║" - echo " ║ Terminal OPC UA Client ║" + echo " ║ opcilloscope uninstaller ║" + echo " ║ terminal OPC UA client ║" echo " ╚═══════════════════════════════════╝" echo "" @@ -117,9 +117,9 @@ uninstall_opcilloscope() { echo "" if [ "$removed_something" = true ]; then - info "Opcilloscope has been uninstalled." + info "opcilloscope has been uninstalled." else - warn "Opcilloscope does not appear to be installed at ${INSTALL_DIR}." + warn "opcilloscope does not appear to be installed at ${INSTALL_DIR}." echo "" echo "If you installed to a custom directory, run:" echo " OPCILLOSCOPE_INSTALL_DIR=/your/path bash uninstall.sh"