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); + } }