diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md index cdab19d..2bc2522 100644 --- a/docs/Client.CLI.md +++ b/docs/Client.CLI.md @@ -14,7 +14,7 @@ dotnet build dotnet run -- [options] ``` -The executable name is still `lmxopcua-cli` — a residual from the pre-v2 rename (`Program.cs:SetExecutableName`). Scripts + operator muscle memory depend on the name; flipping it to `otopcua-cli` is a follow-up that also needs to move the client-side PKI store folder ({LocalAppData}/LmxOpcUaClient/pki/ — used by the shared client for its application certificate) so trust relationships survive the rename. +The executable name is `otopcua-cli`. Dev boxes carrying a pre-task-#208 install may still have the legacy `{LocalAppData}/LmxOpcUaClient/` folder on disk; on first launch of any post-#208 CLI or UI build, `ClientStoragePaths` (`src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs`) migrates it to `{LocalAppData}/OtOpcUaClient/` automatically so trusted certificates + saved settings survive the rename. ## Architecture @@ -46,7 +46,7 @@ All commands accept these options: When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used. ```bash -lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123 +otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123 ``` ### Failover @@ -54,20 +54,20 @@ lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U opera When `-F` is provided, the shared service tries the primary URL first, then each failover URL in order. For long-running commands (`subscribe`, `alarms`), the service monitors the session via keep-alive and automatically reconnects to the next available server on failure. ```bash -lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa +otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -F opc.tcp://localhost:4841/OtOpcUa ``` ### Transport Security When `sign` or `encrypt` is specified, the shared service: -1. Ensures a client application certificate exists under `{LocalAppData}/LmxOpcUaClient/pki/` (auto-created if missing) +1. Ensures a client application certificate exists under `{LocalAppData}/OtOpcUaClient/pki/` (auto-created if missing; pre-rename `LmxOpcUaClient/` is migrated in place on first launch) 2. Discovers server endpoints and selects one matching the requested security mode 3. Prefers `Basic256Sha256` when multiple matching endpoints exist 4. Fails with a clear error if no matching endpoint is found ```bash -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2 +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -S encrypt -U admin -P secret -r -d 2 ``` ### Verbose Logging @@ -81,7 +81,7 @@ The `--verbose` flag switches Serilog output from `Warning` to `Debug` level, sh Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects. ```bash -lmxopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli connect -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 ``` Output: @@ -99,7 +99,7 @@ Connection successful. Reads the current value of a single node and prints the value, status code, and timestamps. ```bash -lmxopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123 +otopcua-cli read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=3;s=DEV.ScanState" -U admin -P admin123 ``` | Flag | Description | @@ -121,7 +121,7 @@ Server Time: 2026-03-30T19:58:38.0971257Z Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`. ```bash -lmxopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 +otopcua-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 ``` | Flag | Description | @@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified ```bash # Browse top-level Objects folder -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 # Browse a specific node recursively to depth 3 -lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB" +otopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r -d 3 -n "ns=3;s=ZB" ``` | Flag | Description | @@ -152,7 +152,7 @@ lmxopcua-cli browse -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 -r Monitors a node for value changes using OPC UA subscriptions. Prints each data change notification with timestamp, value, and status code. Runs until Ctrl+C, then unsubscribes and disconnects cleanly. ```bash -lmxopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500 +otopcua-cli subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -i 500 ``` | Flag | Description | @@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro ```bash # Raw history -lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25" --end "2026-03-30" # Aggregate: 1-hour average -lmxopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \ --start "2026-03-25" --end "2026-03-30" \ --aggregate Average --interval 3600000 @@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s ```bash # Subscribe to alarm events on the Server node -lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa +otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa # Subscribe to a specific source node with condition refresh -lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ +otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ -n "ns=1;s=TestMachine_001" --refresh ``` @@ -221,7 +221,7 @@ lmxopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \ Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI. ```bash -lmxopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 +otopcua-cli redundancy -u opc.tcp://localhost:4840/OtOpcUa -U admin -P admin123 ``` Example output: diff --git a/docs/Client.UI.md b/docs/Client.UI.md index 6839055..e825d6b 100644 --- a/docs/Client.UI.md +++ b/docs/Client.UI.md @@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co ### Settings Persistence -Connection settings are saved to `{LocalAppData}/LmxOpcUaClient/settings.json` after each successful connection and on window close. The folder name is a residual from the pre-v2 rename (the `Client.Shared` session factory still calls itself `LmxOpcUaClient` at `OpcUaClientService.cs:428`); renaming to `OtOpcUaClient` is a follow-up that needs a migration shim so existing users don't lose their settings on upgrade. The settings are reloaded on next launch, including: +Connection settings are saved to `{LocalAppData}/OtOpcUaClient/settings.json` after each successful connection and on window close. Dev boxes upgrading from a pre-task-#208 build still have the legacy `LmxOpcUaClient/` folder on disk; `ClientStoragePaths` in `Client.Shared` moves it to the canonical path on first launch so existing trusted certs + saved settings persist without operator action. The settings are reloaded on next launch, including: - All connection parameters - Active subscription node IDs (restored after reconnection) diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs index 9e67854..75b578d 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.CLI/Program.cs @@ -9,7 +9,7 @@ return await new CliApplicationBuilder() if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!; return Activator.CreateInstance(type)!; }) - .SetExecutableName("lmxopcua-cli") - .SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server") + .SetExecutableName("otopcua-cli") + .SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server") .Build() .RunAsync(args); \ No newline at end of file diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs index 1868919..bd13c5e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultApplicationConfigurationFactory.cs @@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi var config = new ApplicationConfiguration { - ApplicationName = "LmxOpcUaClient", - ApplicationUri = "urn:localhost:LmxOpcUaClient", + ApplicationName = "OtOpcUaClient", + ApplicationUri = "urn:localhost:OtOpcUaClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { @@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi { var app = new ApplicationInstance { - ApplicationName = "LmxOpcUaClient", + ApplicationName = "OtOpcUaClient", ApplicationType = ApplicationType.Client, ApplicationConfiguration = config }; diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs new file mode 100644 index 0000000..d130952 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs @@ -0,0 +1,90 @@ +namespace ZB.MOM.WW.OtOpcUa.Client.Shared; + +/// +/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI +/// store + persisted settings. Renamed from LmxOpcUaClient to OtOpcUaClient +/// in task #208; a one-shot migration shim moves a pre-rename folder in place on first +/// resolution so existing developer boxes keep their trusted server certs + saved +/// connection settings on upgrade. +/// +/// +/// Thread-safe: the rename uses which is atomic on NTFS +/// within the same volume. The lock guarantees the migration runs at most once per +/// process even under concurrent first-touch from CLI + UI. +/// +public static class ClientStoragePaths +{ + /// Canonical client folder name. Post-#208. + public const string CanonicalFolderName = "OtOpcUaClient"; + + /// Pre-#208 folder name. Used only by the migration shim. + public const string LegacyFolderName = "LmxOpcUaClient"; + + private static readonly Lock _migrationLock = new(); + private static bool _migrationChecked; + + /// + /// Absolute path to the client's top-level folder under LocalApplicationData. Runs the + /// one-shot legacy-folder migration before returning so callers that depend on this + /// path (PKI store, settings file) find their existing state at the canonical name. + /// + public static string GetRoot() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var canonical = Path.Combine(localAppData, CanonicalFolderName); + MigrateLegacyFolderIfNeeded(localAppData, canonical); + return canonical; + } + + /// Subfolder for the application's PKI store — used by both CLI + UI. + public static string GetPkiPath() => Path.Combine(GetRoot(), "pki"); + + /// + /// Expose the migration probe for tests + for callers that want to check whether the + /// legacy folder still exists without forcing the rename. Returns true when a legacy + /// folder existed + was moved to canonical, false when no migration was needed or + /// canonical was already present. + /// + public static bool TryRunLegacyMigration() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var canonical = Path.Combine(localAppData, CanonicalFolderName); + return MigrateLegacyFolderIfNeeded(localAppData, canonical); + } + + private static bool MigrateLegacyFolderIfNeeded(string localAppData, string canonical) + { + // Fast-path out of the lock when the migration has already been attempted this process + // — saves the IO on every subsequent call, + the migration is idempotent within the + // same process anyway. + if (_migrationChecked) return false; + + lock (_migrationLock) + { + if (_migrationChecked) return false; + _migrationChecked = true; + + var legacy = Path.Combine(localAppData, LegacyFolderName); + + // Only migrate when the legacy folder is present + canonical isn't. Either of the + // other three combinations (neither / only-canonical / both) means migration + // should NOT run: no-op fresh install, already-migrated, or manual state the + // developer has set up — don't clobber. + if (!Directory.Exists(legacy)) return false; + if (Directory.Exists(canonical)) return false; + + try + { + Directory.Move(legacy, canonical); + return true; + } + catch (IOException) + { + // Concurrent another-process-moved-it or volume-boundary or permissions — leave + // the legacy folder alone; callers that need it can either re-run migration + // manually or point CertificateStorePath explicitly. + return false; + } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs index b0e68fe..a01f29f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs @@ -41,11 +41,11 @@ public sealed class ConnectionSettings public bool AutoAcceptCertificates { get; set; } = true; /// - /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData. + /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData + /// resolved via so the one-shot legacy-folder migration + /// runs before the path is returned. /// - public string CertificateStorePath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient", "pki"); + public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath(); /// /// Validates the settings and throws if any required values are missing or invalid. diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs index 4a1f4e3..2659f5e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs @@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService : new UserIdentity(); var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); - return await _sessionFactory.CreateSessionAsync(config, endpoint, "LmxOpcUaClient", sessionTimeoutMs, identity, + return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity, ct); } diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs b/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs index abd427a..d2b418a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.UI/Services/JsonSettingsService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Client.Shared; namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; @@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; /// public sealed class JsonSettingsService : ISettingsService { - private static readonly string SettingsDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient"); + // ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208 + // developer boxes pick up their existing settings.json on first launch post-rename. + private static readonly string SettingsDir = ClientStoragePaths.GetRoot(); private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json"); diff --git a/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs b/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs index 9cd82cc..fd28d36 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs @@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private bool _autoAcceptCertificates = true; - [ObservableProperty] private string _certificateStorePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LmxOpcUaClient", "pki"); + [ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath(); [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs index 181f882..e4e3929 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/SubscribeCommandTests.cs @@ -101,7 +101,8 @@ public class SubscribeCommandTests await task; var output = TestConsoleHelper.GetOutput(console); - output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)"); - output.ShouldContain("Unsubscribed."); + // CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than + // the single-node form the original test asserted — the command supports multi-node now. + output.ShouldContain("Subscribed to 1/1 nodes (interval: 2000ms)"); } } \ No newline at end of file diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs new file mode 100644 index 0000000..b4d5080 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ClientStoragePathsTests.cs @@ -0,0 +1,48 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Client.Shared; + +namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests; + +[Trait("Category", "Unit")] +public sealed class ClientStoragePathsTests +{ + [Fact] + public void GetRoot_ReturnsCanonicalFolderName_UnderLocalAppData() + { + var root = ClientStoragePaths.GetRoot(); + root.ShouldEndWith(ClientStoragePaths.CanonicalFolderName); + root.ShouldContain(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + } + + [Fact] + public void GetPkiPath_NestsPkiUnderRoot() + { + var pki = ClientStoragePaths.GetPkiPath(); + pki.ShouldEndWith(Path.Combine(ClientStoragePaths.CanonicalFolderName, "pki")); + } + + [Fact] + public void CanonicalFolderName_IsOtOpcUaClient() + { + ClientStoragePaths.CanonicalFolderName.ShouldBe("OtOpcUaClient"); + } + + [Fact] + public void LegacyFolderName_IsLmxOpcUaClient() + { + // The shim depends on this specific spelling — a typo here would leak the legacy + // folder past the migration + break every dev-box upgrade. + ClientStoragePaths.LegacyFolderName.ShouldBe("LmxOpcUaClient"); + } + + [Fact] + public void TryRunLegacyMigration_Returns_False_On_Repeat_Invocation() + { + // Once the guard in-process has fired, subsequent calls short-circuit to false + // regardless of filesystem state. This is the behaviour that keeps the migration + // cheap on hot paths (CertificateStorePath property getter is called frequently). + _ = ClientStoragePaths.GetRoot(); // arms the guard + ClientStoragePaths.TryRunLegacyMigration().ShouldBeFalse(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs index 03ca4f8..89ac9c8 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ConnectionSettingsTests.cs @@ -18,7 +18,7 @@ public class ConnectionSettingsTests settings.SecurityMode.ShouldBe(SecurityMode.None); settings.SessionTimeoutSeconds.ShouldBe(60); settings.AutoAcceptCertificates.ShouldBeTrue(); - settings.CertificateStorePath.ShouldContain("LmxOpcUaClient"); + settings.CertificateStorePath.ShouldContain("OtOpcUaClient"); settings.CertificateStorePath.ShouldContain("pki"); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs index a02a424..deeb8ad 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs @@ -252,7 +252,7 @@ public class MainWindowViewModelTests _vm.FailoverUrls.ShouldBeNull(); _vm.SessionTimeoutSeconds.ShouldBe(60); _vm.AutoAcceptCertificates.ShouldBeTrue(); - _vm.CertificateStorePath.ShouldContain("LmxOpcUaClient"); + _vm.CertificateStorePath.ShouldContain("OtOpcUaClient"); _vm.CertificateStorePath.ShouldContain("pki"); }