Skip to content

Commit 1ccdcfc

Browse files
Merge pull request #9 from GoWithitRoger/feat/bufferbloat-add
feat(bufferbloat): parse, log, and highlight under-load latency + pac…
2 parents a82d1c2 + b42a35e commit 1ccdcfc

File tree

6 files changed

+345
-29
lines changed

6 files changed

+345
-29
lines changed

config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@
4545
PING_RTT_THRESHOLD: float = 30.0
4646
# Any jitter measurement strictly greater than this value (in ms) is an anomaly.
4747
JITTER_THRESHOLD: float = 5.0
48+
# A latency increase (in ms) greater than this is an anomaly (bufferbloat delta).
49+
BUFFERBLOAT_DELTA_THRESHOLD: float = 75.0
50+
# Latency (in ms) during download/upload that is an anomaly.
51+
LATENCY_UNDER_LOAD_THRESHOLD: float = 100.0
52+
# Packet loss percentage from the speedtest that is an anomaly.
53+
SPEEDTEST_PACKET_LOSS_THRESHOLD: float = 0.5
4854

4955
# --- Speed Test Thresholds (in Mbps) ---
5056
# Set separate thresholds for speed tests. Anomalies are values strictly LESS than these.

main.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ def managed_webdriver_session(chrome_options: Options, debug_logger: DebugLogger
121121
debug_logger.log("WebDriver quit: START")
122122
try:
123123
driver.quit()
124+
except Exception as e:
125+
debug_logger.log(f"Ignoring error during driver.quit(): {e}")
124126
finally:
125127
debug_logger.log("WebDriver quit: END")
126128
if service and getattr(service, "process", None):
@@ -167,6 +169,10 @@ class SpeedResults(TypedDict, total=False):
167169
local_downstream_speed: float
168170
local_upstream_speed: float
169171
local_speedtest_jitter: float
172+
# Bufferbloat / under-load metrics from local Ookla CLI JSON
173+
local_latency_down_load_ms: float
174+
local_latency_up_load_ms: float
175+
local_packet_loss_pct: float
170176

171177

172178
class WifiDiagnostics(TypedDict, total=False):
@@ -266,6 +272,13 @@ def log_results(all_data: Mapping[str, str | float | int | None]) -> None:
266272
"Local_Downstream_Mbps": all_data.get("local_downstream_speed"),
267273
"Local_Upstream_Mbps": all_data.get("local_upstream_speed"),
268274
"Local_Speedtest_Jitter_ms": all_data.get("local_speedtest_jitter"),
275+
# Calculated bufferbloat deltas
276+
"Download_Bufferbloat_ms": all_data.get("download_bufferbloat_ms"),
277+
"Upload_Bufferbloat_ms": all_data.get("upload_bufferbloat_ms"),
278+
# New bufferbloat metrics
279+
"Local_Load_Down_ms": all_data.get("local_latency_down_load_ms"),
280+
"Local_Load_Up_ms": all_data.get("local_latency_up_load_ms"),
281+
"Local_Pkt_Loss_Pct": all_data.get("local_packet_loss_pct"),
269282
"WiFi_BSSID": all_data.get("wifi_bssid", "N/A"),
270283
"WiFi_Channel": all_data.get("wifi_channel", "N/A"),
271284
"WiFi_RSSI": all_data.get("wifi_rssi", "N/A"),
@@ -275,7 +288,7 @@ def log_results(all_data: Mapping[str, str | float | int | None]) -> None:
275288

276289
# --- CSV Logging ---
277290
csv_values = [
278-
f"{v:.3f}" if isinstance(v, float) else ("N/A" if v is None else v)
291+
f"{v:.3f}" if isinstance(v, float) else "N/A" if v is None else str(v)
279292
for v in data_points.values()
280293
]
281294
header = "Timestamp," + ",".join(data_points.keys()) + "\n"
@@ -402,6 +415,39 @@ def format_value(
402415
)
403416
print(f" Speedtest Jitter: {speed_jitter}")
404417

418+
# Bufferbloat deltas (idle -> under-load)
419+
down_bloat = format_value(
420+
data_points["Download_Bufferbloat_ms"],
421+
"ms",
422+
config.BUFFERBLOAT_DELTA_THRESHOLD,
423+
precision=2,
424+
)
425+
print(f" Download Bufferbloat: {down_bloat}")
426+
427+
up_bloat = format_value(
428+
data_points["Upload_Bufferbloat_ms"],
429+
"ms",
430+
config.BUFFERBLOAT_DELTA_THRESHOLD,
431+
precision=2,
432+
)
433+
print(f" Upload Bufferbloat: {up_bloat}")
434+
435+
# New bufferbloat metrics
436+
down_load_latency = format_value(
437+
data_points["Local_Load_Down_ms"], "ms", config.LATENCY_UNDER_LOAD_THRESHOLD
438+
)
439+
print(f" Latency (Download Load): {down_load_latency}")
440+
441+
up_load_latency = format_value(
442+
data_points["Local_Load_Up_ms"], "ms", config.LATENCY_UNDER_LOAD_THRESHOLD
443+
)
444+
print(f" Latency (Upload Load): {up_load_latency}")
445+
446+
packet_loss_val = format_value(
447+
data_points["Local_Pkt_Loss_Pct"], "%", config.SPEEDTEST_PACKET_LOSS_THRESHOLD
448+
)
449+
print(f" Speedtest Packet Loss: {packet_loss_val}")
450+
405451
print("\n--- Wi-Fi Diagnostics ---")
406452
print(f" Connected AP (BSSID): {data_points['WiFi_BSSID']}")
407453
print(f" Signal Strength (RSSI): {data_points['WiFi_RSSI']}")
@@ -426,7 +472,12 @@ def run_ping_test_task(driver: WebDriver) -> Optional[GatewayPingResults]:
426472
driver.execute_script("arguments[0].click();", ping_button)
427473
print(f"Gateway ping test started for {config.PING_TARGET}.")
428474
print("Waiting for gateway ping results...")
429-
time.sleep(15)
475+
wait = WebDriverWait(driver, 30)
476+
wait.until(
477+
lambda d: "ping statistics" in d.find_element(By.ID, "progress").get_attribute("value")
478+
)
479+
480+
# Re-find the element after the wait to avoid stale references
430481
results_element = driver.find_element(By.ID, "progress")
431482
results_text = (results_element.get_attribute("value") or "").strip()
432483
if results_text:
@@ -466,13 +517,14 @@ def run_speed_test_task(driver: WebDriver, access_code: str) -> Optional[SpeedRe
466517
run_button = WebDriverWait(driver, 15).until(EC.element_to_be_clickable((By.NAME, "run")))
467518
run_button.click()
468519
print("Gateway speed test initiated. This will take up to 90 seconds...")
469-
WebDriverWait(driver, 90).until(EC.element_to_be_clickable((By.NAME, "run")))
520+
# Wait directly for the results table to appear
521+
# instead of relying on the run button's state
522+
print("Waiting for gateway results table...")
523+
table_selector = (By.CSS_SELECTOR, "table.grid.table100")
524+
table = WebDriverWait(driver, 90).until(EC.visibility_of_element_located(table_selector))
470525
print("Gateway speed test complete. Parsing results...")
471526

472527
results: SpeedResults = {}
473-
table = WebDriverWait(driver, 10).until(
474-
EC.visibility_of_element_located((By.CSS_SELECTOR, "table.grid.table100"))
475-
)
476528
rows = table.find_elements(By.TAG_NAME, "tr")
477529
for row in rows:
478530
cols = row.find_elements(By.TAG_NAME, "td")
@@ -570,11 +622,19 @@ def run_local_speed_test_task() -> Optional[SpeedResults]:
570622
upload_speed = (results.get("upload", {}).get("bandwidth", 0) * 8) / 1_000_000
571623
jitter = results.get("ping", {}).get("jitter", 0.0)
572624

625+
# New parsing logic for bufferbloat
626+
latency_down = results.get("download", {}).get("latency", {}).get("iqm", 0.0)
627+
latency_up = results.get("upload", {}).get("latency", {}).get("iqm", 0.0)
628+
packet_loss = results.get("packetLoss", 0.0)
629+
573630
print("Local speed test complete.")
574631
return {
575632
"local_downstream_speed": download_speed,
576633
"local_upstream_speed": upload_speed,
577634
"local_speedtest_jitter": jitter,
635+
"local_latency_down_load_ms": latency_down,
636+
"local_latency_up_load_ms": latency_up,
637+
"local_packet_loss_pct": packet_loss,
578638
}
579639

580640
except subprocess.CalledProcessError as e:
@@ -765,6 +825,22 @@ def perform_checks() -> None:
765825
# This 'else' block will run if the context manager yields None
766826
print("Skipping gateway tests because WebDriver session failed to start.")
767827

828+
# --- Bufferbloat calculation (download/upload deltas relative to idle WAN RTT) ---
829+
idle_latency = master_results.get("local_wan_rtt_avg_ms")
830+
down_latency = master_results.get("local_latency_down_load_ms")
831+
up_latency = master_results.get("local_latency_up_load_ms")
832+
833+
master_results["download_bufferbloat_ms"] = (
834+
(down_latency - idle_latency)
835+
if (idle_latency is not None and down_latency is not None)
836+
else None
837+
)
838+
master_results["upload_bufferbloat_ms"] = (
839+
(up_latency - idle_latency)
840+
if (idle_latency is not None and up_latency is not None)
841+
else None
842+
)
843+
768844
debug_log.log("perform_checks: END")
769845
log_results(master_results)
770846
print("\n" + "=" * 60 + "\n")

tests/test_bufferbloat.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import os
2+
import sys
3+
from unittest.mock import mock_open, patch
4+
5+
import pytest
6+
7+
# Ensure the main module can be imported
8+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
9+
10+
import config
11+
from main import Colors, log_results, perform_checks
12+
13+
14+
@patch("builtins.open", new_callable=mock_open)
15+
@patch("main.os.path.exists", return_value=False)
16+
@patch("builtins.print")
17+
def test_log_results_includes_bufferbloat_fields_and_highlighting(
18+
mock_print, mock_exists, mock_open_file
19+
) -> None:
20+
# Configure threshold so our values trigger highlighting
21+
config.ENABLE_ANOMALY_HIGHLIGHTING = True
22+
config.BUFFERBLOAT_DELTA_THRESHOLD = 10.0
23+
24+
row = {
25+
# Minimal fields used by log_results
26+
"gateway_loss_percentage": 0.0,
27+
"gateway_rtt_avg_ms": 10.0,
28+
"downstream_speed": None,
29+
"upstream_speed": None,
30+
"local_wan_loss_percentage": 0.0,
31+
"local_wan_rtt_avg_ms": 15.0,
32+
"local_wan_ping_stddev": 1.0,
33+
"local_gw_loss_percentage": 0.0,
34+
"local_gw_rtt_avg_ms": 5.0,
35+
"local_gw_ping_stddev": 0.5,
36+
"local_downstream_speed": 100.0,
37+
"local_upstream_speed": 10.0,
38+
"local_speedtest_jitter": 1.0,
39+
# Bufferbloat deltas already computed upstream
40+
"download_bufferbloat_ms": 15.5, # > 10.0 threshold
41+
"upload_bufferbloat_ms": 11.0, # > 10.0 threshold
42+
# Under-load raw metrics
43+
"local_latency_down_load_ms": 30.5,
44+
"local_latency_up_load_ms": 20.0,
45+
"local_packet_loss_pct": 0.0,
46+
"wifi_bssid": "aa:bb:cc:dd:ee:ff",
47+
"wifi_channel": "1,20",
48+
"wifi_rssi": "-50",
49+
"wifi_noise": "-90",
50+
"wifi_tx_rate": "300",
51+
}
52+
53+
log_results(row)
54+
55+
# CSV header contains the new fields
56+
handle = mock_open_file()
57+
header = handle.mock_calls[1][1][0]
58+
assert "Download_Bufferbloat_ms" in header
59+
assert "Upload_Bufferbloat_ms" in header
60+
61+
# Console output contains highlighted bufferbloat lines
62+
all_output = " ".join([str(call.args[0]) for call in mock_print.call_args_list if call.args])
63+
assert "Download Bufferbloat:" in all_output
64+
assert "Upload Bufferbloat:" in all_output
65+
assert f"{Colors.RED}15.50{Colors.RESET}" in all_output
66+
assert f"{Colors.RED}11.00{Colors.RESET}" in all_output
67+
68+
69+
@patch("main.run_wifi_diagnostics_task", return_value={})
70+
@patch("main.run_local_ping_task")
71+
@patch("main.run_local_speed_test_task")
72+
@patch("main.run_ping_test_task", return_value={})
73+
@patch("main.run_speed_test_task", return_value={})
74+
@patch("main.subprocess.run")
75+
@patch("main.log_results")
76+
@patch("main.webdriver.Chrome")
77+
@patch("main.get_access_code", return_value="code")
78+
@patch("main.ChromeService")
79+
@patch("main.time.sleep")
80+
def test_perform_checks_computes_bufferbloat(
81+
_sleep,
82+
_service,
83+
_access,
84+
_chrome,
85+
mock_log_results,
86+
_subproc,
87+
_gw_speed,
88+
_gw_ping,
89+
mock_local_speed,
90+
mock_local_ping,
91+
_wifi,
92+
monkeypatch,
93+
):
94+
# Arrange config to run local ping and local speed, skip gateway interval
95+
import main as main_module
96+
97+
monkeypatch.setattr(config, "RUN_LOCAL_PING_TEST", True)
98+
monkeypatch.setattr(config, "RUN_LOCAL_GATEWAY_PING_TEST", False)
99+
monkeypatch.setattr(config, "RUN_LOCAL_SPEED_TEST", True)
100+
monkeypatch.setattr(config, "RUN_GATEWAY_SPEED_TEST_INTERVAL", 0)
101+
102+
# Idle RTT and under-load latencies
103+
mock_local_ping.return_value = {
104+
"loss_percentage": 0.0,
105+
"rtt_avg_ms": 20.0,
106+
"ping_stddev": 1.0,
107+
}
108+
mock_local_speed.return_value = {
109+
"local_latency_down_load_ms": 55.0,
110+
"local_latency_up_load_ms": 41.0,
111+
# other fields optional for this test
112+
}
113+
114+
# Reset run counter for deterministic behavior
115+
main_module.run_counter = 0
116+
117+
# Act
118+
perform_checks()
119+
120+
# Assert: log_results called with computed deltas
121+
assert mock_log_results.call_count == 1
122+
args, _ = mock_log_results.call_args
123+
passed = args[0]
124+
assert passed.get("download_bufferbloat_ms") == pytest.approx(35.0)
125+
assert passed.get("upload_bufferbloat_ms") == pytest.approx(21.0)

tests/test_core.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,21 @@
5757
SPEEDTEST_JSON_OUTPUT = (
5858
'{"type": "result", "timestamp": "2025-08-06T22:00:00Z", '
5959
'"ping": {"jitter": 2.789, "latency": 10.123}, '
60-
'"download": {"bandwidth": 37500000, "bytes": 300000000, "elapsed": 8000}, '
61-
'"upload": {"bandwidth": 2500000, "bytes": 20000000, "elapsed": 8000}}'
60+
'"download": {"bandwidth": 37500000, "bytes": 300000000, "elapsed": 8000, '
61+
' "latency": {"iqm": 75.5}}, '
62+
'"upload": {"bandwidth": 2500000, "bytes": 20000000, "elapsed": 8000, '
63+
' "latency": {"iqm": 42.0}}, '
64+
'"packetLoss": 0.25}'
6265
) # 300 Mbps down, 20 Mbps up
6366

67+
# Mock JSON missing bufferbloat fields
68+
SPEEDTEST_JSON_OUTPUT_MISSING = (
69+
'{"type": "result", "timestamp": "2025-08-06T22:00:00Z", '
70+
'"ping": {"jitter": 1.234, "latency": 9.876}, '
71+
'"download": {"bandwidth": 10000000, "bytes": 80000000, "elapsed": 8000}, '
72+
'"upload": {"bandwidth": 2000000, "bytes": 16000000, "elapsed": 8000}}'
73+
) # Missing download/upload latency and packetLoss
74+
6475
# Mock data for Wi-Fi diagnostics
6576
WIFI_DIAG_OUTPUT = "RSSI: -55\nNoise: -90\nTxRate: 866\nChannel: 149,80"
6677
ARP_OUTPUT = "? (192.168.1.1) at a1:b2:c3:d4:e5:f6 on en0 ifscope [ethernet]"
@@ -142,6 +153,29 @@ def test_local_speed_test_parsing(mock_run, mock_exists):
142153
assert results.get("local_downstream_speed") == 300.0
143154
assert results.get("local_upstream_speed") == 20.0
144155
assert results.get("local_speedtest_jitter") == 2.789
156+
assert results.get("local_latency_down_load_ms") == 75.5
157+
assert results.get("local_latency_up_load_ms") == 42.0
158+
assert results.get("local_packet_loss_pct") == 0.25
159+
160+
161+
@patch("main.os.path.exists", return_value=True)
162+
@patch("main.subprocess.run")
163+
def test_local_speed_test_parsing_missing_keys(mock_run, mock_exists):
164+
"""Ookla JSON may omit latency/packetLoss; defaults should be 0.0 without errors."""
165+
mock_run.return_value = MagicMock(
166+
stdout=SPEEDTEST_JSON_OUTPUT_MISSING, returncode=0, stderr=""
167+
)
168+
results = run_local_speed_test_task()
169+
assert results is not None
170+
# Speeds should parse
171+
assert results.get("local_downstream_speed") == 80.0 # 10,000,000 * 8 / 1e6
172+
assert results.get("local_upstream_speed") == 16.0 # 2,000,000 * 8 / 1e6
173+
# Jitter present from ping
174+
assert results.get("local_speedtest_jitter") == 1.234
175+
# Missing fields default to 0.0
176+
assert results.get("local_latency_down_load_ms") == 0.0
177+
assert results.get("local_latency_up_load_ms") == 0.0
178+
assert results.get("local_packet_loss_pct") == 0.0
145179

146180

147181
@patch("main.subprocess.run")

0 commit comments

Comments
 (0)