diff --git a/src/NetworkOptimizer.UniFi/NetworkPathAnalyzer.cs b/src/NetworkOptimizer.UniFi/NetworkPathAnalyzer.cs index e3490ea12..196e99675 100644 --- a/src/NetworkOptimizer.UniFi/NetworkPathAnalyzer.cs +++ b/src/NetworkOptimizer.UniFi/NetworkPathAnalyzer.cs @@ -784,9 +784,26 @@ internal void BuildHopList( } else if (!string.IsNullOrEmpty(currentMac) && currentPort.HasValue) { - // Wired uplink - get port speed from upstream switch - deviceHop.IngressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, currentMac, currentPort); + // Wired uplink - prefer device's reported uplink speed (from API Uplink.Speed), + // fall back to upstream switch's port table if not available. + // This handles scenarios where there's an unmanaged switch between the device + // and the UniFi switch, where the upstream port may report a different speed. + // NOTE: Skip for gateways - their UplinkSpeedMbps is WAN speed, not LAN port speed. + var portTableSpeed = GetPortSpeedFromRawDevices(rawDevices, currentMac, currentPort); + var useDeviceSpeed = targetDevice.UplinkSpeedMbps > 0 && targetDevice.Type != DeviceType.Gateway; + deviceHop.IngressSpeedMbps = useDeviceSpeed ? targetDevice.UplinkSpeedMbps : portTableSpeed; deviceHop.EgressSpeedMbps = deviceHop.IngressSpeedMbps; + + if (useDeviceSpeed && portTableSpeed > 0 && targetDevice.UplinkSpeedMbps != portTableSpeed) + { + _logger.LogDebug("Wired device {Name}: Using device-reported uplink speed {DeviceSpeed}Mbps (upstream port reports {PortSpeed}Mbps)", + targetDevice.Name, targetDevice.UplinkSpeedMbps, portTableSpeed); + } + else + { + _logger.LogDebug("Wired device {Name}: Uplink speed {Speed}Mbps (device={DeviceSpeed}, port={PortSpeed})", + targetDevice.Name, deviceHop.IngressSpeedMbps, targetDevice.UplinkSpeedMbps, portTableSpeed); + } } hops.Add(deviceHop); @@ -1104,9 +1121,23 @@ internal void BuildHopList( } else { + // Wired uplink - prefer device's reported uplink speed (from API Uplink.Speed), + // fall back to upstream device's port table if not available. + // This handles scenarios where there's an unmanaged switch between devices. + // NOTE: Only applies when going TOWARD gateway (main loop). Return path + // (after gateway) uses port table since we're going opposite of uplink direction. + // NOTE: Skip for gateways - their UplinkSpeedMbps is WAN speed, not LAN port speed. hop.EgressPort = device.UplinkPort; - hop.EgressSpeedMbps = GetPortSpeedFromRawDevices(rawDevices, device.UplinkMac, device.UplinkPort); + var portTableSpeed = GetPortSpeedFromRawDevices(rawDevices, device.UplinkMac, device.UplinkPort); + var useDeviceSpeed = device.UplinkSpeedMbps > 0 && device.Type != DeviceType.Gateway; + hop.EgressSpeedMbps = useDeviceSpeed ? device.UplinkSpeedMbps : portTableSpeed; hop.EgressPortName = GetPortName(rawDevices, device.UplinkMac, device.UplinkPort); + + if (useDeviceSpeed && portTableSpeed > 0 && device.UplinkSpeedMbps != portTableSpeed) + { + _logger.LogDebug("Wired hop {Name}: Using device-reported uplink speed {DeviceSpeed}Mbps (upstream port reports {PortSpeed}Mbps)", + device.Name, device.UplinkSpeedMbps, portTableSpeed); + } } } diff --git a/tests/NetworkOptimizer.UniFi.Tests/UplinkSpeedPreferenceTests.cs b/tests/NetworkOptimizer.UniFi.Tests/UplinkSpeedPreferenceTests.cs new file mode 100644 index 000000000..9d2bcec23 --- /dev/null +++ b/tests/NetworkOptimizer.UniFi.Tests/UplinkSpeedPreferenceTests.cs @@ -0,0 +1,369 @@ +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using NetworkOptimizer.Core.Enums; +using NetworkOptimizer.UniFi.Models; +using NetworkOptimizer.UniFi.Tests.Fixtures; +using Xunit; + +namespace NetworkOptimizer.UniFi.Tests; + +/// +/// Tests for uplink speed preference when device-reported speed differs from port table. +/// Verifies fix for issue #189: When an unmanaged switch sits between a UniFi device and +/// the upstream UniFi switch/gateway, the device's reported uplink speed should be trusted +/// over the upstream port table. +/// +public class UplinkSpeedPreferenceTests +{ + private readonly NetworkPathAnalyzer _analyzer; + private readonly Mock _clientProviderMock; + private readonly IMemoryCache _cache; + private readonly Mock _loggerFactoryMock; + + // Test constants + private const string GatewayMac = "aa:bb:cc:00:00:01"; + private const string ApMac = "aa:bb:cc:00:00:03"; + private const string ServerMac = "aa:bb:cc:00:02:01"; + + public UplinkSpeedPreferenceTests() + { + _clientProviderMock = new Mock(); + _cache = new MemoryCache(new MemoryCacheOptions()); + _loggerFactoryMock = new Mock(); + _loggerFactoryMock.Setup(f => f.CreateLogger(It.IsAny())) + .Returns(new Mock().Object); + + _analyzer = new NetworkPathAnalyzer( + _clientProviderMock.Object, + _cache, + _loggerFactoryMock.Object); + } + + /// + /// When a target device (AP) reports 1 GbE uplink but the upstream gateway port shows 10 GbE + /// (unmanaged switch scenario), the path should use the device's reported 1 GbE speed. + /// + [Fact] + public void BuildHopList_TargetDeviceReportsDifferentSpeed_UsesDeviceSpeed() + { + // Arrange - AP reports 1 GbE, but gateway port 9 shows 10 GbE (unmanaged switch in between) + var gateway = NetworkTestData.CreateGateway(mac: GatewayMac); + var ap = NetworkTestData.CreateWiredAccessPoint( + mac: ApMac, + uplinkMac: GatewayMac, + uplinkPort: 9, + uplinkSpeed: 1000); // AP reports 1 GbE + + var topology = new NetworkTopology + { + Devices = new List { gateway, ap }, + Clients = new List(), + Networks = new List + { + new NetworkInfo { Id = "default", Name = "Default", VlanId = 1, IpSubnet = "192.0.2.0/24" } + } + }; + + var serverPosition = new ServerPosition + { + IpAddress = "192.0.2.200", + Mac = ServerMac, + SwitchMac = GatewayMac, + SwitchPort = 1, + VlanId = 1 + }; + + // Raw devices with port table showing 10 GbE on port 9 (the gateway's SFP+ port) + var rawDevices = new Dictionary + { + [GatewayMac] = new UniFiDeviceResponse + { + Mac = GatewayMac, + PortTable = new List + { + new SwitchPort { PortIdx = 1, Speed = 1000, Up = true }, // Server port + new SwitchPort { PortIdx = 9, Speed = 10000, Up = true } // SFP+ to unmanaged switch + } + } + }; + + var path = new NetworkPath + { + SourceHost = serverPosition.IpAddress, + DestinationHost = ap.IpAddress, + RequiresRouting = false + }; + + // Act + _analyzer.BuildHopList(path, serverPosition, ap, null, topology, rawDevices); + + // Assert - AP hop should use device-reported 1 GbE, not port table's 10 GbE + var apHop = path.Hops.FirstOrDefault(h => h.DeviceMac == ApMac); + apHop.Should().NotBeNull("AP should be in the path"); + apHop!.IngressSpeedMbps.Should().Be(1000, + "should use AP's reported uplink speed (1 GbE), not upstream port table (10 GbE)"); + apHop.EgressSpeedMbps.Should().Be(1000, + "egress should match ingress for symmetric link"); + } + + /// + /// When a device reports 0 for uplink speed (not available), should fall back to port table. + /// + [Fact] + public void BuildHopList_DeviceReportsZeroSpeed_FallsBackToPortTable() + { + // Arrange - AP reports 0 (unknown), port table shows 2500 + var gateway = NetworkTestData.CreateGateway(mac: GatewayMac); + var ap = NetworkTestData.CreateWiredAccessPoint( + mac: ApMac, + uplinkMac: GatewayMac, + uplinkPort: 9, + uplinkSpeed: 0); // AP doesn't report speed + + var topology = new NetworkTopology + { + Devices = new List { gateway, ap }, + Clients = new List(), + Networks = new List + { + new NetworkInfo { Id = "default", Name = "Default", VlanId = 1, IpSubnet = "192.0.2.0/24" } + } + }; + + var serverPosition = new ServerPosition + { + IpAddress = "192.0.2.200", + Mac = ServerMac, + SwitchMac = GatewayMac, + SwitchPort = 1, + VlanId = 1 + }; + + var rawDevices = new Dictionary + { + [GatewayMac] = new UniFiDeviceResponse + { + Mac = GatewayMac, + PortTable = new List + { + new SwitchPort { PortIdx = 1, Speed = 1000, Up = true }, + new SwitchPort { PortIdx = 9, Speed = 2500, Up = true } + } + } + }; + + var path = new NetworkPath + { + SourceHost = serverPosition.IpAddress, + DestinationHost = ap.IpAddress, + RequiresRouting = false + }; + + // Act + _analyzer.BuildHopList(path, serverPosition, ap, null, topology, rawDevices); + + // Assert - Should fall back to port table when device reports 0 + var apHop = path.Hops.FirstOrDefault(h => h.DeviceMac == ApMac); + apHop.Should().NotBeNull(); + apHop!.IngressSpeedMbps.Should().Be(2500, + "should fall back to port table (2500) when device reports 0"); + } + + /// + /// When device and port table report the same speed, should use that speed (no override needed). + /// + [Fact] + public void BuildHopList_SpeedsMatch_UsesThatSpeed() + { + // Arrange - Both report 2500 + var gateway = NetworkTestData.CreateGateway(mac: GatewayMac); + var ap = NetworkTestData.CreateWiredAccessPoint( + mac: ApMac, + uplinkMac: GatewayMac, + uplinkPort: 9, + uplinkSpeed: 2500); + + var topology = new NetworkTopology + { + Devices = new List { gateway, ap }, + Clients = new List(), + Networks = new List + { + new NetworkInfo { Id = "default", Name = "Default", VlanId = 1, IpSubnet = "192.0.2.0/24" } + } + }; + + var serverPosition = new ServerPosition + { + IpAddress = "192.0.2.200", + Mac = ServerMac, + SwitchMac = GatewayMac, + SwitchPort = 1, + VlanId = 1 + }; + + var rawDevices = new Dictionary + { + [GatewayMac] = new UniFiDeviceResponse + { + Mac = GatewayMac, + PortTable = new List + { + new SwitchPort { PortIdx = 1, Speed = 1000, Up = true }, + new SwitchPort { PortIdx = 9, Speed = 2500, Up = true } + } + } + }; + + var path = new NetworkPath + { + SourceHost = serverPosition.IpAddress, + DestinationHost = ap.IpAddress, + RequiresRouting = false + }; + + // Act + _analyzer.BuildHopList(path, serverPosition, ap, null, topology, rawDevices); + + // Assert + var apHop = path.Hops.FirstOrDefault(h => h.DeviceMac == ApMac); + apHop.Should().NotBeNull(); + apHop!.IngressSpeedMbps.Should().Be(2500); + } + + /// + /// Mesh APs should continue to use wireless uplink path (not affected by this fix). + /// + [Fact] + public void BuildHopList_MeshAp_UsesWirelessPath() + { + // Arrange + var gateway = NetworkTestData.CreateGateway(mac: GatewayMac); + var wiredAp = NetworkTestData.CreateWiredAccessPoint( + mac: "aa:bb:cc:00:00:02", + uplinkMac: GatewayMac, + uplinkPort: 1, + uplinkSpeed: 1000); + var meshAp = NetworkTestData.CreateMeshAccessPoint( + mac: ApMac, + uplinkMac: "aa:bb:cc:00:00:02", + txRateKbps: 866000, + rxRateKbps: 866000); + + var topology = new NetworkTopology + { + Devices = new List { gateway, wiredAp, meshAp }, + Clients = new List(), + Networks = new List + { + new NetworkInfo { Id = "default", Name = "Default", VlanId = 1, IpSubnet = "192.0.2.0/24" } + } + }; + + var serverPosition = new ServerPosition + { + IpAddress = "192.0.2.200", + Mac = ServerMac, + SwitchMac = GatewayMac, + SwitchPort = 2, + VlanId = 1 + }; + + var path = new NetworkPath + { + SourceHost = serverPosition.IpAddress, + DestinationHost = meshAp.IpAddress, + RequiresRouting = false + }; + + // Act + _analyzer.BuildHopList(path, serverPosition, meshAp, null, topology, new Dictionary()); + + // Assert - Mesh AP should have wireless flags set + var meshHop = path.Hops.FirstOrDefault(h => h.DeviceMac == ApMac); + meshHop.Should().NotBeNull(); + meshHop!.IsWirelessIngress.Should().BeTrue("mesh AP uplink should be marked as wireless"); + meshHop.IsWirelessEgress.Should().BeTrue("mesh AP uplink should be marked as wireless"); + meshHop.IngressSpeedMbps.Should().Be(866, "should use mesh uplink speed (866 Mbps from 866000 Kbps)"); + } + + /// + /// Gateways should NOT use UplinkSpeedMbps (that's WAN speed) - should use port table instead. + /// + [Fact] + public void BuildHopList_GatewayTarget_UsesPortTableNotWanSpeed() + { + // Arrange - Gateway has 1000 Mbps WAN, but LAN port is 2500 Mbps + var gateway = new DiscoveredDevice + { + Mac = GatewayMac, + IpAddress = "192.0.2.1", + Name = "Gateway", + Model = "UDM-Pro", + Type = DeviceType.Gateway, + Adopted = true, + State = 1, + UplinkSpeedMbps = 1000, // This is WAN speed - should NOT be used for LAN path + IsUplinkConnected = true + }; + + var ap = NetworkTestData.CreateWiredAccessPoint( + mac: ApMac, + uplinkMac: GatewayMac, + uplinkPort: 9, + uplinkSpeed: 2500); + + var topology = new NetworkTopology + { + Devices = new List { gateway, ap }, + Clients = new List(), + Networks = new List + { + new NetworkInfo { Id = "default", Name = "Default", VlanId = 1, IpSubnet = "192.0.2.0/24" } + } + }; + + var serverPosition = new ServerPosition + { + IpAddress = "192.0.2.200", + Mac = ServerMac, + SwitchMac = GatewayMac, + SwitchPort = 1, + VlanId = 1 + }; + + // Raw devices with port table - LAN ports are 2500 Mbps + var rawDevices = new Dictionary + { + [GatewayMac] = new UniFiDeviceResponse + { + Mac = GatewayMac, + PortTable = new List + { + new SwitchPort { PortIdx = 1, Speed = 2500, Up = true }, // Server port + new SwitchPort { PortIdx = 9, Speed = 2500, Up = true } // AP port + } + } + }; + + var path = new NetworkPath + { + SourceHost = serverPosition.IpAddress, + DestinationHost = gateway.IpAddress, + RequiresRouting = false, + TargetIsGateway = true + }; + + // Act + _analyzer.BuildHopList(path, serverPosition, gateway, null, topology, rawDevices); + + // Assert - Gateway hop should use port table (2500), NOT UplinkSpeedMbps (1000 WAN) + var gatewayHop = path.Hops.FirstOrDefault(h => h.DeviceMac == GatewayMac); + gatewayHop.Should().NotBeNull("gateway should be in the path"); + // Gateway's ingress/egress should NOT be 1000 (WAN speed) + gatewayHop!.IngressSpeedMbps.Should().NotBe(1000, + "should NOT use gateway's WAN uplink speed for LAN path"); + } +}