diff --git a/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs b/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs index 7e4709b..b1e328a 100644 --- a/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs +++ b/src/Daqifi.Core.Tests/Communication/Transport/TcpStreamTransportTests.cs @@ -136,6 +136,65 @@ public void TcpStreamTransport_ConnectionInfo_ShouldReflectCurrentState() Assert.Contains("127.0.0.1:5000", disconnectedInfo); } + [Fact] + public void TcpStreamTransport_Constructor_WithLocalInterface_ExposesIt() + { + // Arrange & Act + using var transport = new TcpStreamTransport(IPAddress.Loopback, 5000, IPAddress.Loopback); + + // Assert + Assert.Equal(IPAddress.Loopback, transport.LocalInterface); + } + + [Fact] + public void TcpStreamTransport_Constructor_WithoutLocalInterface_LocalInterfaceIsNull() + { + // Arrange & Act + using var transport = new TcpStreamTransport(IPAddress.Loopback, 5000); + + // Assert + Assert.Null(transport.LocalInterface); + } + + [Fact] + public async Task TcpStreamTransport_ConnectAsync_WithUnassignedLocalInterface_ThrowsSocketException() + { + // Arrange - 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is not assigned to any local + // interface, so binding the outbound socket to it must fail with EADDRNOTAVAIL. + // This proves the local-interface argument actually drives the socket bind. + var bogusLocal = IPAddress.Parse("192.0.2.1"); + using var transport = new TcpStreamTransport(IPAddress.Loopback, 1, bogusLocal); + + // Act & Assert + await Assert.ThrowsAsync(() => transport.ConnectAsync()); + Assert.False(transport.IsConnected); + } + + [Fact] + public async Task TcpStreamTransport_ConnectAsync_WithLoopbackLocalInterface_ConnectsAndReportsBoundLocal() + { + // Arrange - real listener on loopback so the connection actually completes + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + using var transport = new TcpStreamTransport(IPAddress.Loopback, port, IPAddress.Loopback); + + // Act + await transport.ConnectAsync(); + + // Assert + Assert.True(transport.IsConnected); + Assert.Contains("127.0.0.1", transport.ConnectionInfo); + await transport.DisconnectAsync(); + } + finally + { + listener.Stop(); + } + } + // Integration test that requires a real server - marked as integration test [Fact(Skip = "Integration test - requires external server")] public async Task TcpStreamTransport_RealConnection_ShouldWorkEndToEnd() diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs index 4a5108f..140ef13 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceFactoryTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using Daqifi.Core.Communication.Transport; using Daqifi.Core.Device; using Daqifi.Core.Device.Discovery; @@ -479,6 +480,47 @@ await Assert.ThrowsAsync( () => DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(deviceInfo, null, cts.Token)); } + [Fact] + public async Task ConnectFromDeviceInfoAsync_WiFiWithLocalInterface_BindsOutboundSocketToIt() + { + // Arrange - 192.0.2.1 is in TEST-NET-1 (RFC 5737) and is not assigned to any local + // interface. If the factory threads LocalInterfaceAddress into the TCP transport, + // the bind will fail with EADDRNOTAVAIL (SocketException) before connect is attempted. + // If the factory ignores LocalInterfaceAddress, the connection to 127.0.0.1 would + // proceed and fail with a different error (or succeed against the listener), so a + // SocketException at bind time is the signal that the wiring is correct. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var deviceInfo = new TestDeviceInfo + { + ConnectionType = ConnectionType.WiFi, + IPAddress = IPAddress.Loopback, + Port = port, + LocalInterfaceAddress = IPAddress.Parse("192.0.2.1") + }; + var options = new DeviceConnectionOptions + { + ConnectionRetry = new ConnectionRetryOptions + { + Enabled = false, + ConnectionTimeout = TimeSpan.FromSeconds(1) + }, + InitializeDevice = false + }; + + // Act & Assert + await Assert.ThrowsAsync( + () => DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(deviceInfo, options)); + } + finally + { + listener.Stop(); + } + } + #endregion #region ConnectFromDeviceInfo Sync Tests diff --git a/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs b/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs index 30ec53e..b3ffde0 100644 --- a/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs +++ b/src/Daqifi.Core/Communication/Transport/TcpStreamTransport.cs @@ -11,6 +11,7 @@ namespace Daqifi.Core.Communication.Transport; public class TcpStreamTransport : IStreamTransport { private readonly IPEndPoint _endPoint; + private readonly IPAddress? _localInterface; private TcpClient? _tcpClient; private NetworkStream? _networkStream; private bool _disposed; @@ -20,9 +21,15 @@ public class TcpStreamTransport : IStreamTransport /// /// The IP address to connect to. /// The port to connect to. - public TcpStreamTransport(IPAddress ipAddress, int port) + /// + /// Optional local interface address to bind the outbound socket to. When supplied, the TCP + /// connection egresses on the specified NIC; required for multi-homed hosts where the OS + /// would otherwise pick the wrong interface for the route. + /// + public TcpStreamTransport(IPAddress ipAddress, int port, IPAddress? localInterface = null) { _endPoint = new IPEndPoint(ipAddress, port); + _localInterface = localInterface; } /// @@ -30,7 +37,12 @@ public TcpStreamTransport(IPAddress ipAddress, int port) /// /// The hostname to connect to. /// The port to connect to. - public TcpStreamTransport(string host, int port) + /// + /// Optional local interface address to bind the outbound socket to. When supplied, the TCP + /// connection egresses on the specified NIC; required for multi-homed hosts where the OS + /// would otherwise pick the wrong interface for the route. + /// + public TcpStreamTransport(string host, int port, IPAddress? localInterface = null) { if (IPAddress.TryParse(host, out var ipAddress)) { @@ -42,6 +54,7 @@ public TcpStreamTransport(string host, int port) _endPoint = new IPEndPoint(IPAddress.None, port); Hostname = host; } + _localInterface = localInterface; } /// @@ -49,6 +62,11 @@ public TcpStreamTransport(string host, int port) /// public string? Hostname { get; } + /// + /// Gets the local interface address the outbound socket will bind to, or null to let the OS choose. + /// + public IPAddress? LocalInterface => _localInterface; + /// /// Gets the underlying stream for read/write operations. /// @@ -126,7 +144,9 @@ public async Task ConnectAsync(ConnectionRetryOptions? retryOptions) } } - _tcpClient = new TcpClient(); + _tcpClient = _localInterface != null + ? new TcpClient(new IPEndPoint(_localInterface, 0)) + : new TcpClient(); // Set timeouts from retry options or use defaults var timeout = (int)options.ConnectionTimeout.TotalMilliseconds; diff --git a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs index ecd491d..d58b25c 100644 --- a/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs +++ b/src/Daqifi.Core/Device/DaqifiDeviceFactory.cs @@ -291,6 +291,8 @@ private static async Task ConnectWiFiDeviceAsync( nameof(deviceInfo)); } + ValidatePort(deviceInfo.Port.Value); + var effectiveOptions = options ?? DeviceConnectionOptions.Default; // Use the device name from the discovery info if not overridden in options @@ -306,11 +308,15 @@ private static async Task ConnectWiFiDeviceAsync( InitializeDevice = effectiveOptions.InitializeDevice }; - return await ConnectTcpAsync( + // Honor LocalInterfaceAddress so multi-homed hosts egress on the NIC that + // discovered the device, not whichever NIC the OS routing table prefers. + var transport = new TcpStreamTransport( deviceInfo.IPAddress, deviceInfo.Port.Value, - modifiedOptions, - cancellationToken).ConfigureAwait(false); + deviceInfo.LocalInterfaceAddress); + + return await ConnectWithTransportAsync(transport, modifiedOptions, cancellationToken) + .ConfigureAwait(false); } ///