Compare commits

...

3 Commits

Author SHA1 Message Date
Joseph Doherty
f9bc301c33 Client rename residuals: lmxopcua-cli → otopcua-cli + LmxOpcUaClient → OtOpcUaClient with migration shim. Closes task #208 (the executable-name + LocalAppData-folder slice that was called out in Client.CLI.md / Client.UI.md as a deliberately-deferred residual of the Phase 0 rename). Six source references flipped to the canonical OtOpcUaClient spelling: Program.cs CliFx executable name + description (lmxopcua-cli → otopcua-cli), DefaultApplicationConfigurationFactory.cs ApplicationName + ApplicationUri (LmxOpcUaClient + urn:localhost:LmxOpcUaClient → OtOpcUaClient + urn:localhost:OtOpcUaClient), OpcUaClientService.CreateSessionAsync session-name arg, ConnectionSettings.CertificateStorePath default, MainWindowViewModel.CertificateStorePath default, JsonSettingsService.SettingsDir. Two consuming tests (ConnectionSettingsTests + MainWindowViewModelTests) updated to assert the new canonical name. New ClientStoragePaths static helper at src/ZB.MOM.WW.OtOpcUa.Client.Shared/ClientStoragePaths.cs is the migration shim — single entry point for the PKI root + pki subpath, runs a one-shot legacy-folder probe on first resolution: if {LocalAppData}/LmxOpcUaClient/ exists + {LocalAppData}/OtOpcUaClient/ does not, Directory.Move renames it in place (atomic on NTFS within the same volume) so trusted server certs + saved connection settings persist across the rename without operator action. Idempotent per-process via a Lock-guarded _migrationChecked flag so repeated CertificateStorePath getter calls on the hot path pay no IO cost beyond the first. Fresh-install path (neither folder exists) + already-migrated path (only canonical exists) + manual-override path (both exist — developer has set up something explicit) are all no-ops that leave state alone. IOException on the Directory.Move is swallowed + logged as a false return so a concurrent peer process losing the race doesn't crash the consumer; the losing process falls back to whatever state exists. Five new ClientStoragePathsTests assert: GetRoot ends with canonical name under LocalAppData, GetPkiPath nests pki under root, CanonicalFolderName is OtOpcUaClient, LegacyFolderName is LmxOpcUaClient (the migration contract — a typo here would leak the legacy folder past the shim), repeat invocation returns false after first-touch arms the in-process guard. Doc-side residual-explanation notes in docs/Client.CLI.md + docs/Client.UI.md are dropped now that the rename is real; replaced with a short "pre-#208 dev boxes migrate automatically on first launch" note that points at ClientStoragePaths. Sample CLI invocations in Client.CLI.md updated via sed from lmxopcua-cli to otopcua-cli across every command block (14 replacements). Pre-existing staleness in SubscribeCommandTests.Execute_PrintsSubscriptionMessage surfaced during the test run — the CLI's subscribe command has long since switched to an aggregate "Subscribed to {count}/{total} nodes (interval: ...)" output format but the test still asserted the original single-node form. Updated the assertion to match current output + added a comment explaining the change; this is unrelated to the rename but was blocking a green Client.CLI.Tests run. Full solution build 0 errors; Client.Shared.Tests 136/136 + 5 new shim tests passing; Client.UI.Tests 98/98; Client.CLI.Tests 52/52 (was 51/52 before the subscribe-test fix). No Admin/Core/Server changes — this touches only the client layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:50:40 -04:00
Joseph Doherty
12d748c4f3 CLAUDE.md — TopShelf + LdapAuthenticationProvider stale references. Closes task #207. The docs-refresh agent sweep (PR #149) flagged two stale library/class references in the root CLAUDE.md that the v2 refactors landed but the project-level instructions missed. Service hosting line replaced with the two-process reality: Server + Admin use .NET generic-host AddWindowsService (decision #30 explicitly replaced TopShelf in v2 — OpcUaServerService.cs carries the decision-#30 comment inline); Galaxy.Host is a plain console app wrapped by NSSM because its .NET-Framework-4.8-x86 target can't use the generic-host Windows-service integration + MXAccess COM bitness requirement pins it there anyway. The LDAP-auth mention gains the actual class name LdapUserAuthenticator (src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs) implementing IUserAuthenticator — previously claimed LdapAuthenticationProvider + IUserAuthenticationProvider + IRoleProvider, none of which exist in the source tree (the docs-refresh agent grepped for it; it's truly gone). No functional impact — CLAUDE.md is operator-facing + informs future agent runs about the stack, not compile-time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:41:16 -04:00
e9b1d107ab Merge pull request (#149) - Doc refresh for multi-driver OtOpcUa 2026-04-20 01:37:00 -04:00
14 changed files with 178 additions and 39 deletions

View File

@@ -87,13 +87,14 @@ The server supports non-transparent warm/hot redundancy via the `Redundancy` sec
## LDAP Authentication ## LDAP Authentication
The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapAuthenticationProvider` implements both `IUserAuthenticationProvider` and `IRoleProvider`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference. The server uses LDAP-based user authentication via the `Authentication.Ldap` section in `appsettings.json`. When enabled, credentials are validated by LDAP bind against a GLAuth server (installed at `C:\publish\glauth\`), and LDAP group membership maps to OPC UA permissions: `ReadOnly` (browse/read), `WriteOperate` (write FreeAccess/Operate attributes), `WriteTune` (write Tune attributes), `WriteConfigure` (write Configure attributes), `AlarmAck` (alarm acknowledgment). `LdapUserAuthenticator` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/LdapUserAuthenticator.cs`) implements `IUserAuthenticator`. See `docs/Security.md` for the full guide and `C:\publish\glauth\auth.md` for LDAP user/group reference.
## Library Preferences ## Library Preferences
- **Logging**: Serilog with rolling daily file sink - **Logging**: Serilog with rolling daily file sink
- **Unit tests**: xUnit + Shouldly for assertions - **Unit tests**: xUnit + Shouldly for assertions
- **Service hosting**: TopShelf (Windows service install/uninstall/run as console) - **Service hosting (Server, Admin)**: .NET generic host with `AddWindowsService` (decision #30 — replaced TopShelf in v2; see `src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs`)
- **Service hosting (Galaxy.Host)**: plain console app wrapped by NSSM (`.NET Framework 4.8 x86` — required by MXAccess COM bitness)
- **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server` - **OPC UA**: OPC Foundation UA .NET Standard stack (https://github.com/opcfoundation/ua-.netstandard) — NuGet: `OPCFoundation.NetStandard.Opc.Ua.Server`
## OPC UA .NET Standard Documentation ## OPC UA .NET Standard Documentation

View File

@@ -14,7 +14,7 @@ dotnet build
dotnet run -- <command> [options] dotnet run -- <command> [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 (<code>{LocalAppData}/LmxOpcUaClient/pki/</code> — 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 ## 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. 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 ```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 ### 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. 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 ```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 ### Transport Security
When `sign` or `encrypt` is specified, the shared service: 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 2. Discovers server endpoints and selects one matching the requested security mode
3. Prefers `Basic256Sha256` when multiple matching endpoints exist 3. Prefers `Basic256Sha256` when multiple matching endpoints exist
4. Fails with a clear error if no matching endpoint is found 4. Fails with a clear error if no matching endpoint is found
```bash ```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 ### 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. Tests connectivity to an OPC UA server. Creates a session, prints connection metadata, and disconnects.
```bash ```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: Output:
@@ -99,7 +99,7 @@ Connection successful.
Reads the current value of a single node and prints the value, status code, and timestamps. Reads the current value of a single node and prints the value, status code, and timestamps.
```bash ```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 | | 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()`. 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 ```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 | | Flag | Description |
@@ -135,10 +135,10 @@ Browses the OPC UA address space starting from the Objects folder or a specified
```bash ```bash
# Browse top-level Objects folder # 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 # 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 | | 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. 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 ```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 | | Flag | Description |
@@ -166,12 +166,12 @@ Reads historical data from a node. Supports raw history reads and aggregate (pro
```bash ```bash
# Raw history # 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" \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30" --start "2026-03-25" --end "2026-03-30"
# Aggregate: 1-hour average # 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" \ -n "ns=1;s=TestMachine_001.TestHistoryValue" \
--start "2026-03-25" --end "2026-03-30" \ --start "2026-03-25" --end "2026-03-30" \
--aggregate Average --interval 3600000 --aggregate Average --interval 3600000
@@ -203,10 +203,10 @@ Subscribes to alarm events on a node. Prints structured alarm output including s
```bash ```bash
# Subscribe to alarm events on the Server node # 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 # 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 -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. Reads the OPC UA redundancy state from a server: redundancy mode, service level, server URIs, and application URI.
```bash ```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: Example output:

View File

@@ -65,7 +65,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
### Settings Persistence ### 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 - All connection parameters
- Active subscription node IDs (restored after reconnection) - Active subscription node IDs (restored after reconnection)

View File

@@ -9,7 +9,7 @@ return await new CliApplicationBuilder()
if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!; if (type.IsSubclassOf(typeof(CommandBase))) return Activator.CreateInstance(type, CommandBase.DefaultFactory)!;
return Activator.CreateInstance(type)!; return Activator.CreateInstance(type)!;
}) })
.SetExecutableName("lmxopcua-cli") .SetExecutableName("otopcua-cli")
.SetDescription("LmxOpcUa CLI - command-line client for the LmxOpcUa OPC UA server") .SetDescription("OtOpcUa CLI - command-line client for the OtOpcUa OPC UA server")
.Build() .Build()
.RunAsync(args); .RunAsync(args);

View File

@@ -18,8 +18,8 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
var config = new ApplicationConfiguration var config = new ApplicationConfiguration
{ {
ApplicationName = "LmxOpcUaClient", ApplicationName = "OtOpcUaClient",
ApplicationUri = "urn:localhost:LmxOpcUaClient", ApplicationUri = "urn:localhost:OtOpcUaClient",
ApplicationType = ApplicationType.Client, ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration SecurityConfiguration = new SecurityConfiguration
{ {
@@ -60,7 +60,7 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
{ {
var app = new ApplicationInstance var app = new ApplicationInstance
{ {
ApplicationName = "LmxOpcUaClient", ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client, ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config ApplicationConfiguration = config
}; };

View File

@@ -0,0 +1,90 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared;
/// <summary>
/// Resolves the canonical under-LocalAppData folder for the shared OPC UA client's PKI
/// store + persisted settings. Renamed from <c>LmxOpcUaClient</c> to <c>OtOpcUaClient</c>
/// 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.
/// </summary>
/// <remarks>
/// Thread-safe: the rename uses <see cref="Directory.Move"/> 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.
/// </remarks>
public static class ClientStoragePaths
{
/// <summary>Canonical client folder name. Post-#208.</summary>
public const string CanonicalFolderName = "OtOpcUaClient";
/// <summary>Pre-#208 folder name. Used only by the migration shim.</summary>
public const string LegacyFolderName = "LmxOpcUaClient";
private static readonly Lock _migrationLock = new();
private static bool _migrationChecked;
/// <summary>
/// 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.
/// </summary>
public static string GetRoot()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var canonical = Path.Combine(localAppData, CanonicalFolderName);
MigrateLegacyFolderIfNeeded(localAppData, canonical);
return canonical;
}
/// <summary>Subfolder for the application's PKI store — used by both CLI + UI.</summary>
public static string GetPkiPath() => Path.Combine(GetRoot(), "pki");
/// <summary>
/// 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.
/// </summary>
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;
}
}
}
}

View File

@@ -41,11 +41,11 @@ public sealed class ConnectionSettings
public bool AutoAcceptCertificates { get; set; } = true; public bool AutoAcceptCertificates { get; set; } = true;
/// <summary> /// <summary>
/// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData. /// Path to the certificate store. Defaults to a subdirectory under LocalApplicationData
/// resolved via <see cref="ClientStoragePaths"/> so the one-shot legacy-folder migration
/// runs before the path is returned.
/// </summary> /// </summary>
public string CertificateStorePath { get; set; } = Path.Combine( public string CertificateStorePath { get; set; } = ClientStoragePaths.GetPkiPath();
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
/// <summary> /// <summary>
/// Validates the settings and throws if any required values are missing or invalid. /// Validates the settings and throws if any required values are missing or invalid.

View File

@@ -425,7 +425,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
: new UserIdentity(); : new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000); 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); ct);
} }

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services; namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
@@ -7,9 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Client.UI.Services;
/// </summary> /// </summary>
public sealed class JsonSettingsService : ISettingsService public sealed class JsonSettingsService : ISettingsService
{ {
private static readonly string SettingsDir = Path.Combine( // ClientStoragePaths.GetRoot runs the one-shot legacy-folder migration so pre-#208
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), // developer boxes pick up their existing settings.json on first launch post-rename.
"LmxOpcUaClient"); private static readonly string SettingsDir = ClientStoragePaths.GetRoot();
private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json"); private static readonly string SettingsPath = Path.Combine(SettingsDir, "settings.json");

View File

@@ -21,9 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private bool _autoAcceptCertificates = true; [ObservableProperty] private bool _autoAcceptCertificates = true;
[ObservableProperty] private string _certificateStorePath = Path.Combine( [ObservableProperty] private string _certificateStorePath = ClientStoragePaths.GetPkiPath();
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LmxOpcUaClient", "pki");
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))]

View File

@@ -101,7 +101,8 @@ public class SubscribeCommandTests
await task; await task;
var output = TestConsoleHelper.GetOutput(console); var output = TestConsoleHelper.GetOutput(console);
output.ShouldContain("Subscribed to ns=2;s=TestVar (interval: 2000ms)"); // CLI now prints aggregate form "Subscribed to {count}/{total} nodes (interval: ...)" rather than
output.ShouldContain("Unsubscribed."); // the single-node form the original test asserted — the command supports multi-node now.
output.ShouldContain("Subscribed to 1/1 nodes (interval: 2000ms)");
} }
} }

View File

@@ -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();
}
}

View File

@@ -18,7 +18,7 @@ public class ConnectionSettingsTests
settings.SecurityMode.ShouldBe(SecurityMode.None); settings.SecurityMode.ShouldBe(SecurityMode.None);
settings.SessionTimeoutSeconds.ShouldBe(60); settings.SessionTimeoutSeconds.ShouldBe(60);
settings.AutoAcceptCertificates.ShouldBeTrue(); settings.AutoAcceptCertificates.ShouldBeTrue();
settings.CertificateStorePath.ShouldContain("LmxOpcUaClient"); settings.CertificateStorePath.ShouldContain("OtOpcUaClient");
settings.CertificateStorePath.ShouldContain("pki"); settings.CertificateStorePath.ShouldContain("pki");
} }

View File

@@ -252,7 +252,7 @@ public class MainWindowViewModelTests
_vm.FailoverUrls.ShouldBeNull(); _vm.FailoverUrls.ShouldBeNull();
_vm.SessionTimeoutSeconds.ShouldBe(60); _vm.SessionTimeoutSeconds.ShouldBe(60);
_vm.AutoAcceptCertificates.ShouldBeTrue(); _vm.AutoAcceptCertificates.ShouldBeTrue();
_vm.CertificateStorePath.ShouldContain("LmxOpcUaClient"); _vm.CertificateStorePath.ShouldContain("OtOpcUaClient");
_vm.CertificateStorePath.ShouldContain("pki"); _vm.CertificateStorePath.ShouldContain("pki");
} }