Durchlauf 1 ist gemessen (Ergebnis folgt separat im Runbook). Fuer den Durchlauf
mit laufendem Videocall brauchte es Werkzeug, das es noch nicht gab:
- tp6-monitor-core.sh haengt EINE persistente SSH-Sitzung an den Core und laesst
dort eine Console-Schleife laufen, die im 2-s-Takt CPU, WAN-Rate und die
Registration-Table-Werte eines WLAN-Clients als CSV ausgibt. Nur Lesezugriffe.
Unter CAPsMAN fuehrt der Manager die Tabelle, deshalb wird der Core abgefragt
und nicht ap1/ap2. Zwei RouterOS-Fallen sind im Code kommentiert: die Console
parst eine per SSH uebergebene Mehrzeilen-Eingabe zeilenweise, und in der
Einzeiler-Form braucht jeder do={}-Block ein abschliessendes Semikolon.
- tp6-marker/server.py ist die Konsole fuer die Person am Telefon: Phasenkarte
mit Klartext-Anweisung, Countdown, Fortschritt, Marker-Buttons und Not-Aus,
bedienbar vom iPad. Die Buttons sind server-seitig gerenderte Formulare und
die Anleitung aktualisiert sich notfalls per meta-refresh -- auf dem iPad ist
das JavaScript ausgefallen, uebrig blieb nur der eine statische Button.
- tp6-call-run.sh fuehrt Durchlauf 2 mit denselben Messparametern wie Durchlauf 1
(3 Flows x 430 KB/s, 24 Pings, 30 s Settle) und ergaenzt Ruhephasen davor und
danach, Phasenmarken, eine PID-Datei fuer den Not-Aus und einen Heartbeat.
Der Heartbeat ist kein Detail: ohne ihn geht der Start-Klick still ins Leere,
der Call laeuft ohne Last, und "keine Probleme" liest sich wie ein Ergebnis,
obwohl es eine Nicht-Messung ist -- genau so passiert, 5,5 Minuten lang.
- tp6-auswertung.py schneidet die Sampler-CSV an den Phasenmarken auf und meldet
neben min/avg/max auch die Ereignisse, die im Mittelwert untergehen: Roaming,
Re-Assoc und Verschwinden des Clients.
- tp6-bufferbloat-messung.sh schickt jetzt User-Agent und Referer mit. Ohne die
beantwortet speed.cloudflare.com /__down mit 403 und einem Byte: die Flows
sind sofort tot, die Ping-Reihe sieht sauber aus, und die Download-Seite gilt
faelschlich als bufferbloat-frei. Aufgefallen ist es nur, weil der Sampler am
Core 164 kbit statt 50 Mbit zeigte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
150 lines
5.5 KiB
Python
150 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
TP6 — Auswertung: Sampler-CSV + Marker-CSV -> Zahlen je Phase.
|
|
|
|
Ohne das ist die Sampler-CSV nur ein Haufen Zeilen. Schneidet die CSV an den
|
|
PHASE_*-Marken auf und rechnet je Phase min/avg/max fuer Core-CPU, WAN-Rate und —
|
|
sofern ein Client mitgesampelt wurde — Signal und Raten des WLAN-Clients.
|
|
|
|
Zusaetzlich werden die Ereignisse gemeldet, die im Mittelwert untergehen:
|
|
Roaming (Interface-Wechsel), Re-Assoc (Uptime-Ruecksprung), Verschwinden (absent)
|
|
und die Nutzer-Marker (ICH/GELI schlecht, Kamera, Not-Aus).
|
|
|
|
Aufruf:
|
|
python3 docs/tp6-auswertung.py --sampler <sampler.csv> [--markers <markers.csv>]
|
|
"""
|
|
import argparse
|
|
import csv
|
|
import sys
|
|
|
|
|
|
def num(v):
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def stats(vals):
|
|
vals = [v for v in vals if v is not None]
|
|
if not vals:
|
|
return None
|
|
return min(vals), sum(vals) / len(vals), max(vals)
|
|
|
|
|
|
def fmt_bps(v):
|
|
if v is None:
|
|
return "—"
|
|
return "%.1f Mbit" % (v / 1e6) if v >= 1e6 else "%.0f kbit" % (v / 1e3)
|
|
|
|
|
|
def fmt_rate(v):
|
|
return "—" if v is None else "%.0f Mbps" % (v / 1e6)
|
|
|
|
|
|
def hms(sec):
|
|
return "%02d:%02d" % (sec // 60, sec % 60)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--sampler", required=True)
|
|
ap.add_argument("--markers")
|
|
args = ap.parse_args()
|
|
|
|
rows = list(csv.DictReader(open(args.sampler)))
|
|
rows = [r for r in rows if (r.get("ts_epoch") or "").isdigit()]
|
|
if not rows:
|
|
sys.exit("Sampler-CSV enthaelt keine Datenzeilen.")
|
|
|
|
marks = []
|
|
if args.markers:
|
|
for m in csv.DictReader(open(args.markers)):
|
|
if (m.get("ts_epoch") or "").isdigit():
|
|
marks.append((int(m["ts_epoch"]), m["ts_local"], m["marker"]))
|
|
marks.sort()
|
|
|
|
# Phasenfenster aus den PHASE_*-Marken; ohne Marker die gesamte Aufzeichnung.
|
|
phases = [(ts, name[6:]) for ts, _, name in marks if name.startswith("PHASE_")]
|
|
t0, tEnd = int(rows[0]["ts_epoch"]), int(rows[-1]["ts_epoch"])
|
|
windows = []
|
|
if phases:
|
|
for i, (ts, name) in enumerate(phases):
|
|
end = phases[i + 1][0] if i + 1 < len(phases) else tEnd + 1
|
|
windows.append((name, ts, end))
|
|
else:
|
|
windows.append(("gesamte Aufzeichnung", t0, tEnd + 1))
|
|
|
|
has_sta = any((r.get("sta_signal") or "").strip() for r in rows)
|
|
print("Sampler: %d Zeilen, %s bis %s\n" % (len(rows), rows[0]["ts_local"], rows[-1]["ts_local"]))
|
|
|
|
for name, start, end in windows:
|
|
win = [r for r in rows if start <= int(r["ts_epoch"]) < end]
|
|
if not win:
|
|
continue
|
|
dur = int(win[-1]["ts_epoch"]) - int(win[0]["ts_epoch"])
|
|
print("── %s (%s, %d Samples, %s)" % (name, win[0]["ts_local"], len(win), hms(dur)))
|
|
|
|
cpu = stats([num(r["cpu"]) for r in win])
|
|
rx = stats([num(r["wan_rx_bps"]) for r in win])
|
|
tx = stats([num(r["wan_tx_bps"]) for r in win])
|
|
if cpu:
|
|
print(" Core-CPU min %.0f%% avg %.1f%% max %.0f%%" % cpu)
|
|
if rx:
|
|
print(" WAN rx avg %s max %s" % (fmt_bps(rx[1]), fmt_bps(rx[2])))
|
|
if tx:
|
|
print(" WAN tx avg %s max %s" % (fmt_bps(tx[1]), fmt_bps(tx[2])))
|
|
|
|
if has_sta:
|
|
absent = sum(1 for r in win if (r.get("sta_iface") or "") == "absent")
|
|
sig = stats([num(r["sta_signal"]) for r in win])
|
|
trate = stats([num(r["sta_tx_rate"]) for r in win])
|
|
rrate = stats([num(r["sta_rx_rate"]) for r in win])
|
|
stx = stats([num(r["sta_tx_bps"]) for r in win])
|
|
srx = stats([num(r["sta_rx_bps"]) for r in win])
|
|
ifaces = []
|
|
for r in win:
|
|
i = r.get("sta_iface") or ""
|
|
if i and (not ifaces or ifaces[-1] != i):
|
|
ifaces.append(i)
|
|
if sig:
|
|
print(" Client-Sig min %.0f avg %.1f max %.0f dBm" % sig)
|
|
if trate and rrate:
|
|
print(" Client-Rate tx min %s / avg %s rx min %s / avg %s"
|
|
% (fmt_rate(trate[0]), fmt_rate(trate[1]), fmt_rate(rrate[0]), fmt_rate(rrate[1])))
|
|
if stx and srx:
|
|
print(" Client-Last tx avg %s / max %s rx avg %s / max %s"
|
|
% (fmt_bps(stx[1]), fmt_bps(stx[2]), fmt_bps(srx[1]), fmt_bps(srx[2])))
|
|
print(" AP-Pfad %s%s" % (" -> ".join(ifaces) or "—",
|
|
" ⚠️ %d Samples ABSENT" % absent if absent else ""))
|
|
|
|
ereignisse = [(t, l, n) for t, l, n in marks if start <= t < end and not n.startswith("PHASE_")]
|
|
for _, lt, n in ereignisse:
|
|
print(" ⚑ %s %s" % (lt, n))
|
|
print()
|
|
|
|
# Re-Assoc erkennen: Uptime springt zurueck.
|
|
if has_sta:
|
|
reassoc = []
|
|
prev = None
|
|
for r in rows:
|
|
up = (r.get("sta_uptime") or "").strip()
|
|
if not up or up == "absent":
|
|
prev = None
|
|
continue
|
|
parts = [int(p) for p in up.split(":")] if up.count(":") == 2 else None
|
|
secs = parts[0] * 3600 + parts[1] * 60 + parts[2] if parts else None
|
|
if secs is not None and prev is not None and secs < prev:
|
|
reassoc.append((r["ts_local"], r.get("sta_iface", "")))
|
|
prev = secs
|
|
if reassoc:
|
|
print("⚠️ Re-Assoc / Neuverbindung des Clients:")
|
|
for lt, i in reassoc:
|
|
print(" %s auf %s" % (lt, i))
|
|
else:
|
|
print("Keine Re-Assoc des Clients im gesamten Fenster.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|