Skip to content

Commit 7260278

Browse files
committed
RF-aware load imbalance, exclude gateway-only consoles, fix AP tie-breaking (#512)
* RF-aware load imbalance rule, extend roaming topology to 72h Load Imbalance: When APs are placed on the Signal Map, use RF propagation modeling to check if the overloaded and underloaded APs are in separate coverage zones. If they are distant and all clients on the busy AP have strong signal, suppress the warning entirely. If distant but some clients are weak, downgrade to Info. Hints users to place APs on the map when propagation context isn't available. Roaming Topology: Pass within=259200 (72h) to the UniFi roaming topology API for a broader view of roaming failures instead of the default 24h. * Exclude gateway-only consoles from Wi-Fi Optimizer, revert roaming to 24h UDM-Pro, UDM-SE, UDM-Pro-Max, and EFG report radio_table entries in the UniFi API but have no actual Wi-Fi radios. Filter them out using the FriendlyModelName: exclude gateways starting with "UDM-" or equal to "EFG". The original UDM (Dream Machine) passes through since it has real Wi-Fi. Also reverts the roaming topology lookback from 72h back to the default 24h. * Also exclude EFG-Core and future EFG variants from Wi-Fi Optimizer * Fix load imbalance picking same AP for both max and min When multiple APs have the same client count, OrderByDescending and OrderBy could non-deterministically pick the same AP for both maxAp and minAp, producing messages like "X has 8 clients while X has only 8". Fix: use opposite MAC tie-breaking (ThenBy vs ThenByDescending) to guarantee different APs, plus a MAC equality guard as a safety net. * Add tests for load imbalance rule and gateway AP exclusion LoadImbalanceRuleTests (13 tests): - Basic threshold behavior (single AP, no clients, balanced, imbalanced) - Tie-breaking: ensures maxAp != minAp when client counts are equal - RF distance: suppressed when far apart + strong signal, Info when weak - Propagation context edge cases (one AP not placed, null signal) GatewayApExclusionTests (18 tests): - UDM-Pro, UDM-SE, UDM-Pro-Max excluded (start with "UDM-") - EFG and EFG-Core excluded (start with "EFG") - UDM, UDR, UDR7, UX, UX7, UDW, UDR-5G-Max allowed (have real Wi-Fi) - Device model/shortname values match real API responses * Adjust AP radio card column widths in Wi-Fi Optimizer * Adjust radio-channel width to 45px in AP cards
1 parent 7645ea0 commit 7260278

5 files changed

Lines changed: 671 additions & 7 deletions

File tree

src/NetworkOptimizer.UniFi/UniFiDiscovery.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,31 @@ public async Task<List<DiscoveredDevice>> DiscoverDevicesAsync(CancellationToken
126126
/// Discovers all devices with wireless radios for WiFi Optimizer.
127127
/// Includes traditional APs (type=uap), UDM/UX mesh APs, and gateway-class devices
128128
/// (UDR, UX, UDM) that have integrated wireless radios broadcasting Wi-Fi.
129+
/// Excludes gateway-only consoles (UDM-Pro, UDM-SE, UDM-Pro-Max, EFG) that report
130+
/// radio_table entries in the API but don't actually have Wi-Fi radios.
129131
/// SmartPower devices (USP-Strip, USP-Plug) are excluded via DeviceType classification.
130132
/// </summary>
131133
public async Task<List<DiscoveredDevice>> DiscoverAccessPointsAsync(CancellationToken cancellationToken = default)
132134
{
133135
var devices = await DiscoverDevicesAsync(cancellationToken);
134136
return devices.Where(d =>
135137
d.Type == DeviceType.AccessPoint ||
136-
(d.Type == DeviceType.Gateway && d.RadioTable is { Count: > 0 })).ToList();
138+
(d.Type == DeviceType.Gateway && d.RadioTable is { Count: > 0 } && !IsGatewayOnlyConsole(d))).ToList();
139+
}
140+
141+
/// <summary>
142+
/// Returns true for gateway-class consoles that do NOT have integrated Wi-Fi radios.
143+
/// The UniFi API sometimes reports radio_table entries for these devices even though
144+
/// they have no wireless capability. Uses FriendlyModelName (the UI display name)
145+
/// as the source of truth rather than trusting API radio data.
146+
/// Excludes: UDM-Pro, UDM-SE, UDM-Pro-Max (start with "UDM-"), EFG, EFG-Core (start with "EFG").
147+
/// Allows: UDM (original Dream Machine), UDR, UX, etc. which have real Wi-Fi.
148+
/// </summary>
149+
internal static bool IsGatewayOnlyConsole(DiscoveredDevice device)
150+
{
151+
var name = device.FriendlyModelName;
152+
return name.StartsWith("UDM-", StringComparison.OrdinalIgnoreCase) ||
153+
name.StartsWith("EFG", StringComparison.OrdinalIgnoreCase);
137154
}
138155

139156
/// <summary>

src/NetworkOptimizer.Web/wwwroot/css/app.css

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7665,20 +7665,20 @@ a.path-hop.hop-clickable:hover {
76657665

76667666
.radio-channel {
76677667
color: var(--text-secondary);
7668-
width: 44px;
7668+
width: 45px;
76697669
flex-shrink: 0;
76707670
}
76717671

76727672
.radio-eirp {
76737673
color: var(--text-muted);
76747674
font-size: 0.75rem;
7675-
width: 50px;
7675+
width: 47px;
76767676
flex-shrink: 0;
76777677
}
76787678

76797679
.radio-util {
76807680
font-weight: 500;
7681-
width: 58px;
7681+
width: 54px;
76827682
flex-shrink: 0;
76837683
}
76847684

@@ -7688,6 +7688,7 @@ a.path-hop.hop-clickable:hover {
76887688

76897689
.radio-clients {
76907690
color: var(--text-muted);
7691+
width: 60px;
76917692
margin-left: auto;
76927693
}
76937694

src/NetworkOptimizer.WiFi/Rules/LoadImbalanceRule.cs

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
using NetworkOptimizer.WiFi.Models;
2+
using NetworkOptimizer.WiFi.Services;
23

34
namespace NetworkOptimizer.WiFi.Rules;
45

56
/// <summary>
67
/// Rule that warns when there is significant load imbalance across APs,
78
/// which can cause some APs to be overloaded while others are underutilized.
9+
/// Uses RF propagation modeling (when available) to suppress warnings for APs
10+
/// that are too far apart to share clients.
811
/// </summary>
912
public class LoadImbalanceRule : IWiFiOptimizerRule
1013
{
14+
private readonly PropagationService _propagationService;
15+
16+
public LoadImbalanceRule(PropagationService propagationService)
17+
{
18+
_propagationService = propagationService;
19+
}
20+
1121
public string RuleId => "WIFI-LOAD-IMBALANCE-001";
1222

1323
/// <summary>
1424
/// Coefficient of variation threshold (percentage) above which to warn.
1525
/// </summary>
1626
private const double ImbalanceThreshold = 50;
1727

28+
/// <summary>
29+
/// Signal strength (dBm) at or above which a client is considered well-connected.
30+
/// </summary>
31+
private const int StrongSignalThreshold = -65;
32+
1833
public HealthIssue? Evaluate(WiFiOptimizerContext ctx)
1934
{
2035
// Only relevant for multi-AP deployments
@@ -35,8 +50,94 @@ public class LoadImbalanceRule : IWiFiOptimizerRule
3550
if (imbalance < ImbalanceThreshold)
3651
return null;
3752

38-
var maxAp = ctx.AccessPoints.OrderByDescending(a => a.TotalClients).First();
39-
var minAp = ctx.AccessPoints.OrderBy(a => a.TotalClients).First();
53+
// Stable tie-breaking: when multiple APs have the same client count, use MAC to
54+
// guarantee maxAp and minAp are different APs (opposite MAC sort direction).
55+
var maxAp = ctx.AccessPoints.OrderByDescending(a => a.TotalClients).ThenBy(a => a.Mac).First();
56+
var minAp = ctx.AccessPoints.OrderBy(a => a.TotalClients).ThenByDescending(a => a.Mac).First();
57+
58+
// Safety: if they resolved to the same AP (e.g., single AP after filtering), bail
59+
if (maxAp.Mac.Equals(minAp.Mac, StringComparison.OrdinalIgnoreCase))
60+
return null;
61+
62+
// RF distance check: if both APs are placed on the floor plan, use propagation
63+
// modeling to determine if they're in separate coverage zones. If the APs are
64+
// too far apart for clients to roam between them, load imbalance is expected.
65+
if (ctx.PropagationContext != null)
66+
{
67+
var maxMac = maxAp.Mac.ToLowerInvariant();
68+
var minMac = minAp.Mac.ToLowerInvariant();
69+
70+
if (ctx.PropagationContext.ApsByMac.TryGetValue(maxMac, out var maxProp) &&
71+
ctx.PropagationContext.ApsByMac.TryGetValue(minMac, out var minProp))
72+
{
73+
// Check if the APs can reach each other on any common band.
74+
// Use the same interference threshold as co-channel checks (-70 dBm).
75+
var bands = new[] { "5", "2.4", "6" };
76+
var apsInterfere = false;
77+
foreach (var band in bands)
78+
{
79+
// Only check bands both APs have active radios on
80+
var bandEnum = band switch
81+
{
82+
"2.4" => RadioBand.Band2_4GHz,
83+
"5" => RadioBand.Band5GHz,
84+
"6" => RadioBand.Band6GHz,
85+
_ => RadioBand.Band5GHz
86+
};
87+
if (!maxAp.Radios.Any(r => r.Band == bandEnum && r.Channel.HasValue) ||
88+
!minAp.Radios.Any(r => r.Band == bandEnum && r.Channel.HasValue))
89+
continue;
90+
91+
if (_propagationService.DoApsInterfere(maxProp, minProp, band,
92+
ctx.PropagationContext.WallsByFloor, ctx.PropagationContext.Buildings))
93+
{
94+
apsInterfere = true;
95+
break;
96+
}
97+
}
98+
99+
if (!apsInterfere)
100+
{
101+
// APs are RF-distant (separate coverage zones) - imbalance is expected.
102+
// Additionally confirm: if clients on the overloaded AP all have strong
103+
// signals, they're well-placed and shouldn't be steered elsewhere.
104+
var clientsOnMaxAp = ctx.Clients
105+
.Where(c => c.ApMac.Equals(maxAp.Mac, StringComparison.OrdinalIgnoreCase))
106+
.ToList();
107+
108+
var allStrongSignal = clientsOnMaxAp.Count > 0 &&
109+
clientsOnMaxAp.All(c => c.Signal.HasValue && c.Signal.Value >= StrongSignalThreshold);
110+
111+
if (allStrongSignal)
112+
{
113+
// All clients on the busy AP have strong signal and the quiet AP is
114+
// far away - this is definitively a separate coverage zone, suppress entirely
115+
return null;
116+
}
117+
118+
// APs are distant but some clients have weak signal - could indicate
119+
// a coverage gap rather than a load balancing issue. Downgrade to Info.
120+
return new HealthIssue
121+
{
122+
Severity = HealthIssueSeverity.Info,
123+
Dimensions = { HealthDimension.CapacityHeadroom },
124+
Title = "Significant Load Imbalance",
125+
Description = $"{maxAp.Name} has {maxAp.TotalClients} clients while {minAp.Name} has only {minAp.TotalClients}. " +
126+
$"These APs are in separate coverage zones so some imbalance is expected, " +
127+
$"but some clients on {maxAp.Name} have weak signal.",
128+
AffectedEntity = $"{maxAp.Name} ({maxAp.TotalClients}), {minAp.Name} ({minAp.TotalClients})",
129+
Recommendation = "Check if weak-signal clients on the busy AP could benefit from additional coverage in that zone.",
130+
ScoreImpact = -2
131+
};
132+
}
133+
}
134+
}
135+
136+
var recommendation = "Consider lowering TX power on the overloaded AP or tightening minimum RSSI to encourage roaming to nearby APs.";
137+
138+
// Hint about floor plan placement if propagation context isn't available
139+
if (ctx.PropagationContext == null)
140+
recommendation += " Place your APs on the Signal Map to enable RF distance analysis - this issue may be suppressed if the APs are in separate coverage zones.";
40141

41142
return new HealthIssue
42143
{
@@ -46,7 +147,7 @@ public class LoadImbalanceRule : IWiFiOptimizerRule
46147
Description = $"{maxAp.Name} has {maxAp.TotalClients} clients while {minAp.Name} has only {minAp.TotalClients}. " +
47148
$"This imbalance ({imbalance:F0}%) can cause performance issues on overloaded APs.",
48149
AffectedEntity = $"{maxAp.Name} ({maxAp.TotalClients}), {minAp.Name} ({minAp.TotalClients})",
49-
Recommendation = "Consider lowering TX power on the overloaded AP or tightening minimum RSSI to encourage roaming to nearby APs. This may be expected if these APs serve different coverage zones. No action needed unless clients have a stronger signal available from another AP.",
150+
Recommendation = recommendation,
50151
ScoreImpact = -8
51152
};
52153
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using FluentAssertions;
2+
using NetworkOptimizer.Core.Enums;
3+
using NetworkOptimizer.UniFi.Models;
4+
using Xunit;
5+
6+
namespace NetworkOptimizer.UniFi.Tests;
7+
8+
/// <summary>
9+
/// Tests that gateway-only consoles (no Wi-Fi radios) are excluded from the
10+
/// Wi-Fi Optimizer AP list, even when the UniFi API reports phantom radio_table entries.
11+
/// Device model/shortname values come from real API responses.
12+
/// </summary>
13+
public class GatewayApExclusionTests
14+
{
15+
/// <summary>
16+
/// Creates a DiscoveredDevice matching real UniFi API device response data.
17+
/// Model and Shortname must match the production database so FriendlyModelName resolves correctly.
18+
/// </summary>
19+
private static DiscoveredDevice CreateGatewayDevice(
20+
string model, string? shortname, int radioCount = 0)
21+
{
22+
var device = new DiscoveredDevice
23+
{
24+
Id = Guid.NewGuid().ToString(),
25+
Mac = $"aa:bb:cc:{Guid.NewGuid().ToString()[..8]}",
26+
Name = $"Test {shortname ?? model}",
27+
Type = DeviceType.Gateway,
28+
HardwareType = DeviceType.Gateway,
29+
Model = model,
30+
Shortname = shortname,
31+
IpAddress = "192.0.2.1",
32+
RadioTable = radioCount > 0
33+
? Enumerable.Range(0, radioCount).Select(i => new RadioTableEntry { Name = $"wifi{i}" }).ToList()
34+
: null
35+
};
36+
return device;
37+
}
38+
39+
// ---------------------------------------------------------------
40+
// Gateway-only consoles: must be excluded even with radio_table
41+
// ---------------------------------------------------------------
42+
43+
[Theory]
44+
[InlineData("UDMPRO", "UDMPRO", "UDM-Pro")] // Dream Machine Pro
45+
[InlineData("UDMPROSE", "UDMPROSE", "UDM-SE")] // Dream Machine SE
46+
[InlineData("UDMPROMAX", "UDMPROMAX", "UDM-Pro-Max")] // Dream Machine Pro Max
47+
public void UdmProFamily_Excluded(string model, string shortname, string expectedFriendlyName)
48+
{
49+
var device = CreateGatewayDevice(model, shortname, radioCount: 2);
50+
51+
device.FriendlyModelName.Should().Be(expectedFriendlyName,
52+
$"model={model} shortname={shortname} should resolve to {expectedFriendlyName}");
53+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeTrue(
54+
$"{expectedFriendlyName} has no Wi-Fi radios");
55+
}
56+
57+
[Theory]
58+
[InlineData("UDMENT", "UDMENT", "EFG")] // Enterprise Fortress Gateway
59+
[InlineData("EFG", "EFG", "EFG")] // EFG via shortname alias
60+
public void Efg_Excluded(string model, string shortname, string expectedFriendlyName)
61+
{
62+
var device = CreateGatewayDevice(model, shortname, radioCount: 2);
63+
64+
device.FriendlyModelName.Should().Be(expectedFriendlyName);
65+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeTrue(
66+
$"{expectedFriendlyName} has no Wi-Fi radios");
67+
}
68+
69+
[Fact]
70+
public void EfgCore_ExcludedByPrefix()
71+
{
72+
// EFG-Core not yet in the product database, but FriendlyModelName would
73+
// fall back to the shortname. Verify the StartsWith("EFG") prefix catches it.
74+
var device = CreateGatewayDevice("EFGCORE", "EFG-Core", radioCount: 2);
75+
76+
// FriendlyModelName may be "EFG-Core" (from shortname fallback) or whatever the DB resolves
77+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeTrue(
78+
"EFG-Core starts with 'EFG' and should be excluded");
79+
}
80+
81+
[Theory]
82+
[InlineData("UXGPRO", "UXGPRO")] // Gateway Pro
83+
[InlineData("UXGENT", "UXGENT")] // Gateway Enterprise
84+
[InlineData("UDMA6A8", "UCGF")] // Cloud Gateway Fiber
85+
[InlineData("UDRULT", "UDRULT")] // UCG-Ultra
86+
[InlineData("UXGB", "UXGB")] // Gateway Max
87+
public void OtherGatewayOnly_NotMatchedButNoRadios(string model, string shortname)
88+
{
89+
// These don't start with "UDM-" or "EFG" but also have no Wi-Fi.
90+
// They're not caught by IsGatewayOnlyConsole, but they also won't
91+
// have radio_table entries in the real API, so the RadioTable check
92+
// in DiscoverAccessPointsAsync filters them out.
93+
var device = CreateGatewayDevice(model, shortname, radioCount: 0);
94+
95+
// No radio_table → filtered by the Count > 0 check, not by IsGatewayOnlyConsole
96+
device.RadioTable.Should().BeNull();
97+
}
98+
99+
// ---------------------------------------------------------------
100+
// Gateways WITH real Wi-Fi: must be allowed through
101+
// ---------------------------------------------------------------
102+
103+
[Fact]
104+
public void Udm_Allowed()
105+
{
106+
// Original Dream Machine - has real Wi-Fi radios
107+
var device = CreateGatewayDevice("UDM", "UDM", radioCount: 2);
108+
109+
device.FriendlyModelName.Should().Be("UDM");
110+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
111+
"UDM (original Dream Machine) has integrated Wi-Fi");
112+
}
113+
114+
[Fact]
115+
public void Udr_Allowed()
116+
{
117+
// Dream Router - has real Wi-Fi radios
118+
var device = CreateGatewayDevice("UDR", "UDR", radioCount: 2);
119+
120+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
121+
"UDR has integrated Wi-Fi");
122+
}
123+
124+
[Fact]
125+
public void Udr7_Allowed()
126+
{
127+
// Dream Router 7 - has real Wi-Fi radios (from sample-device-resp-udr7.txt)
128+
var device = CreateGatewayDevice("UDMA67A", "UDR7", radioCount: 3);
129+
130+
device.FriendlyModelName.Should().Be("UDR7");
131+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
132+
"UDR7 has integrated Wi-Fi");
133+
}
134+
135+
[Fact]
136+
public void Ux_Allowed()
137+
{
138+
// Express - has real Wi-Fi radios
139+
var device = CreateGatewayDevice("UX", "UX", radioCount: 2);
140+
141+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
142+
"UX (Express) has integrated Wi-Fi");
143+
}
144+
145+
[Fact]
146+
public void Ux7_Allowed()
147+
{
148+
// Express 7 - has real Wi-Fi radios
149+
var device = CreateGatewayDevice("UDMA69B", "UX7", radioCount: 3);
150+
151+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
152+
"UX7 (Express 7) has integrated Wi-Fi");
153+
}
154+
155+
[Fact]
156+
public void Udw_Allowed()
157+
{
158+
// Dream Wall - has real Wi-Fi radios
159+
var device = CreateGatewayDevice("UDW", "UDW", radioCount: 3);
160+
161+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
162+
"UDW (Dream Wall) has integrated Wi-Fi");
163+
}
164+
165+
[Fact]
166+
public void Udr5GMax_Allowed()
167+
{
168+
// Dream Router 5G Max - has real Wi-Fi radios
169+
var device = CreateGatewayDevice("UDMA6B9", "UDR-5G-Max", radioCount: 3);
170+
171+
UniFiDiscovery.IsGatewayOnlyConsole(device).Should().BeFalse(
172+
"UDR-5G-Max has integrated Wi-Fi");
173+
}
174+
}

0 commit comments

Comments
 (0)