diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
index a07dbc1a..b19e20fb 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
@@ -183,9 +183,10 @@ public class HistoryReadCommand : CommandBase
await console.Output.WriteLineAsync();
await console.Output.WriteLineAsync($"{values.Count} values returned.");
}
- catch (ServiceResultException ex)
+ catch (ServiceResultException ex) when (ex.InnerException is null)
{
// e.g. HistoryReadStatus.ThrowIfRefused: a refusal is an operator-facing outcome, not a crash.
+ // A wrapped failure (certificate validation, identity rejection on connect) keeps its full detail.
throw new CommandException(ex.Message);
}
finally
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs
index 9ced2a15..c16c020e 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Adapters/DefaultEndpointDiscovery.cs
@@ -92,13 +92,15 @@ internal static class EndpointSelector
// Only when the scheme matches: a different scheme (https vs opc.tcp) is a different transport.
var serverUri = new Uri(best.EndpointUrl);
var requestedUri = new Uri(endpointUrl);
+ // A URL typed without a port (opc.tcp has no registered default, so Port is -1) keeps the advertised port.
+ var port = requestedUri.Port > 0 ? requestedUri.Port : serverUri.Port;
if (string.Equals(serverUri.Scheme, requestedUri.Scheme, StringComparison.OrdinalIgnoreCase)
- && (serverUri.Host != requestedUri.Host || serverUri.Port != requestedUri.Port))
+ && (serverUri.Host != requestedUri.Host || serverUri.Port != port))
{
- var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = requestedUri.Port };
+ var builder = new UriBuilder(best.EndpointUrl) { Host = requestedUri.Host, Port = port };
best.EndpointUrl = builder.ToString();
Logger.Debug("Rewrote endpoint from {ServerHost}:{ServerPort} to {RequestedHost}:{RequestedPort}",
- serverUri.Host, serverUri.Port, requestedUri.Host, requestedUri.Port);
+ serverUri.Host, serverUri.Port, requestedUri.Host, port);
}
return best;
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
index 5ebc0e46..ba917d82 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Adapters/EndpointSelectorTests.cs
@@ -197,4 +197,30 @@ public class EndpointSelectorTests
best.EndpointUrl.ShouldBe("opc.tcp://server:4840");
}
+
+ /// A URL typed without a port keeps the advertised port (opc.tcp has no default; Uri.Port is -1).
+ [Fact]
+ public void SelectBest_KeepsAdvertisedPort_WhenRequestedUrlHasNoPort()
+ {
+ var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
+
+ var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://localhost", MessageSecurityMode.None);
+
+ var uri = new Uri(best.EndpointUrl);
+ uri.Host.ShouldBe("localhost");
+ uri.Port.ShouldBe(4840);
+ }
+
+ /// An IPv6 host is rewritten with its brackets intact.
+ [Fact]
+ public void SelectBest_RewritesIpv6Host()
+ {
+ var endpoints = new[] { Ep(MessageSecurityMode.None, SecurityPolicies.None, "opc.tcp://internal-host:4840/OtOpcUa") };
+
+ var best = EndpointSelector.SelectBest(endpoints, "opc.tcp://[::1]:4841", MessageSecurityMode.None);
+
+ var uri = new Uri(best.EndpointUrl);
+ uri.Host.ShouldBe("[::1]");
+ uri.Port.ShouldBe(4841);
+ }
}