Files
bf-infra/docs/tp6-marker/server.py
Christoph Emsenhuber 3aac2eeb8a TP6 Durchlauf 2 messbar machen: Sampler, gefuehrte Konsole, Treiber
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>
2026-08-20 22:51:11 +02:00

322 lines
13 KiB
Python

#!/usr/bin/env python3
"""
TP6 — Klick-Konsole fuer den Call-Durchlauf.
Zwei Aufgaben:
1. FUEHREN: die Seite sagt jederzeit, was gerade laeuft, wie lange es noch dauert und
was der Mensch am Telefon jetzt tun (und lassen) soll. Am Ende sagt sie unmissver-
staendlich, dass die Messung abgeschlossen ist.
2. MITSCHREIBEN: jeder Klick landet mit Zeitstempel in derselben Zeitachse wie der
Core-Sampler. Der Not-Aus killt zusaetzlich sofort die Saettigungs-Flows.
🔴 Zwei Dinge, die hier absichtlich so gebaut sind:
- Die Buttons sind server-seitig gerenderte <form>-POSTs. Faellt das JavaScript aus
(auf dem iPad am 2026-08-20 passiert: es blieb nur der eine statische Button uebrig),
funktioniert die Konsole trotzdem — die Seite laedt dann nach jedem Klick neu.
- Die Seite hat ein <meta refresh>. Ohne JS aktualisiert sich die Anleitung dadurch
trotzdem; laeuft JS, entfernt es das Meta-Tag und pollt stattdessen leise.
Aendert nichts am Netz. Bindet auf 0.0.0.0 (Bedienung vom iPad).
"""
import json
import os
import signal
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
RUN_DIR = os.environ.get(
"TP6_RUN_DIR",
"/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/"
"0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2",
)
SAMPLER = os.environ.get("TP6_SAMPLER", os.path.join(RUN_DIR, "sampler.csv"))
PORT = int(os.environ.get("TP6_PORT", "3031"))
MARKERS = os.path.join(RUN_DIR, "markers.csv")
PIDFILE = os.path.join(RUN_DIR, "flows.pid")
STATE = os.path.join(RUN_DIR, "phase.json")
GO_FLAG = os.path.join(RUN_DIR, "go.flag")
ABORT_FLAG = os.path.join(RUN_DIR, "abort.flag")
os.makedirs(RUN_DIR, exist_ok=True)
if not os.path.exists(MARKERS):
with open(MARKERS, "w") as fh:
fh.write("ts_epoch,ts_local,marker\n")
# name, label, css-Klasse, in welchen Modi sichtbar
BUTTONS = [
("CALL_START", "📞 Call verbunden — Messung starten", "start", ("wartet",)),
("ME_BAD", "🔴 ICH höre/sehe schlecht", "bad-me", ("messung", "langzeit")),
("GELI_BAD", "🟠 GELI meldet: hört/sieht mich schlecht", "bad-geli", ("messung", "langzeit")),
("SYNC_OFF", "🎬 Lippen nicht synchron", "bad-me", ("messung", "langzeit")),
("OK_AGAIN", "🟢 Wieder gut", "ok", ("messung", "langzeit")),
("CAM_OFF", "📷 Kamera aus", "cam", ("messung", "langzeit")),
("CAM_ON", "📷 Kamera wieder an", "cam", ("messung", "langzeit")),
("WALK", "🚶 Ich gehe jetzt los", "walk", ("langzeit",)),
("ARRIVED", "📍 Ich stehe still", "walk", ("langzeit",)),
("CALL_END", "📴 Call beendet", "end", ("messung", "langzeit")),
]
DEFAULT_STATE = {
"mode": "wartet",
"phase": "—",
"title": "Kein Treiber",
"instruction": "Es läuft kein Messtreiber. Ein Start-Klick würde nichts messen.",
"step": 0, "steps": 0, "ends": 0,
}
def read_state():
try:
with open(STATE) as fh:
st = json.load(fh)
st["driver"] = (time.time() - os.path.getmtime(STATE)) < 20
return st
except Exception:
st = dict(DEFAULT_STATE)
st["driver"] = False
return st
def write_marker(name):
with open(MARKERS, "a") as fh:
fh.write("%d,%s,%s\n" % (time.time(), time.strftime("%H:%M:%S"), name))
fh.flush()
def last_marker():
try:
rows = [r.strip() for r in open(MARKERS) if r.strip()]
if len(rows) < 2:
return "—"
p = rows[-1].split(",")
return "%s (%s)" % (p[2], p[1])
except Exception:
return "—"
def sampler_tail():
"""Letzte Sampler-Zeile fuer die Live-Anzeige. Fehlt sie, ist das kein Fehler."""
try:
rows = [r.strip() for r in open(SAMPLER) if r.strip()]
if len(rows) < 2:
return {}
row = dict(zip(rows[0].split(","), rows[-1].split(",")))
return {
"age": int(time.time() - int(row.get("ts_epoch", "0"))),
"cpu": row.get("cpu", ""), "wan_rx": row.get("wan_rx_bps", ""),
"wan_tx": row.get("wan_tx_bps", ""), "iface": row.get("sta_iface", ""),
"signal": row.get("sta_signal", ""), "tx_rate": row.get("sta_tx_rate", ""),
"sta_tx_bps": row.get("sta_tx_bps", ""), "sta_rx_bps": row.get("sta_rx_bps", ""),
}
except Exception:
return {}
def kill_flows():
"""Not-Aus: Saettigungs-Flows sofort beenden."""
killed = 0
try:
pids = [int(p) for p in open(PIDFILE).read().split() if p.strip().isdigit()]
except Exception:
pids = []
for pid in pids:
try:
os.kill(pid, signal.SIGKILL)
killed += 1
except Exception:
pass
open(ABORT_FLAG, "w").close()
return killed
AP = {"cap-wifi1": "ap1 / 5 GHz", "cap-wifi2": "ap1 / 2,4 GHz",
"cap-wifi3": "ap2 / 5 GHz", "cap-wifi4": "ap2 / 2,4 GHz"}
def fmt_bps(v):
try:
n = int(v)
except (TypeError, ValueError):
return "—"
return "%.1f Mbit" % (n / 1e6) if n >= 1e6 else "%d kbit" % (n // 1000)
def render(st):
w = sampler_tail()
rest = max(0, int(st.get("ends", 0) - time.time())) if st.get("ends") else 0
mode = st.get("mode", "wartet")
banner = ""
if not st.get("driver"):
banner = ('<div class="banner warn">⚠️ KEIN TREIBER — ein Start-Klick misst NICHTS. '
'Erst den Treiber starten lassen.</div>')
elif mode == "langzeit":
banner = ('<div class="banner done">✅ MESSUNG ABGESCHLOSSEN — die Zahlen liegen vor. '
'Ab jetzt läuft nur noch die Langzeit-Aufzeichnung.</div>')
prog = ""
if st.get("steps"):
prog = '<div class="steps">Schritt %d von %d</div>' % (st.get("step", 0), st["steps"])
countdown = ('<div class="cd" id="cd" data-ends="%d">noch ~%d s</div>' % (st.get("ends", 0), rest)
if rest else '<div class="cd" id="cd" data-ends="0"></div>')
wlan = "—"
if w.get("iface"):
wlan = "%s · %s dBm · tx %s / rx %s" % (
AP.get(w["iface"], w["iface"]), w.get("signal", "?"),
fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps")))
btns = "\n".join(
'<form method="post" action="/m?type=%s" onsubmit="return send(event,\'%s\')">'
'<button class="%s" type="submit">%s</button></form>' % (n, n, c, l)
for n, l, c, modes in BUTTONS if mode in modes
)
return PAGE.format(
banner=banner, title=st.get("title", "—"), phase=st.get("phase", "—"),
instruction=st.get("instruction", ""), prog=prog, countdown=countdown,
wlan=wlan, wan="rx %s / tx %s · CPU %s%%" % (fmt_bps(w.get("wan_rx")),
fmt_bps(w.get("wan_tx")), w.get("cpu", "?")),
last=last_marker(), buttons=btns,
)
PAGE = """<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="8" id="metaref">
<title>TP6 — Call-Konsole</title><style>
:root{{color-scheme:dark}}
body{{margin:0;padding:14px;background:#14161a;color:#e8eaed;
font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}}
.banner{{padding:12px 14px;border-radius:10px;font-weight:700;margin-bottom:12px}}
.warn{{background:#7f1d1d;color:#fff}} .done{{background:#14532d;color:#d1fae5}}
.card{{background:#1c1f26;border:1px solid #2a2f3a;border-radius:12px;padding:14px;margin-bottom:12px}}
.phase{{font-size:13px;color:#7dd3fc;text-transform:uppercase;letter-spacing:.06em}}
h2{{margin:4px 0 8px;font-size:21px}}
.inst{{font-size:17px;color:#e8eaed}}
.cd{{margin-top:10px;font:600 15px ui-monospace,Menlo,monospace;color:#fbbf24}}
.steps{{margin-top:6px;font-size:13px;color:#9aa4b2}}
.live{{background:#1c1f26;border:1px solid #2a2f3a;border-radius:10px;padding:10px 12px;
margin-bottom:14px;font:13px/1.7 ui-monospace,Menlo,monospace;white-space:pre-wrap;color:#9aa4b2}}
button{{width:100%;margin:0 0 10px;padding:20px 16px;font-size:19px;font-weight:600;border:none;
border-radius:12px;background:#262b34;color:#e8eaed;-webkit-tap-highlight-color:transparent}}
button:active{{filter:brightness(1.3)}}
.start{{background:#14532d}} .bad-me{{background:#7f1d1d}} .bad-geli{{background:#7c3f12}}
.ok{{background:#166534}} .cam{{background:#2b3648}} .walk{{background:#1e3a5f}} .end{{background:#3a2b4a}}
.abort{{background:#b91c1c;font-size:23px;padding:30px 16px;box-shadow:0 0 0 3px #7f1d1d inset}}
form{{margin:0}}
</style>
{banner}
<div class="card">
<div class="phase">{phase}</div>
<h2>{title}</h2>
<div class="inst">{instruction}</div>
{countdown}
{prog}
</div>
<div class="live">WLAN {wlan}
WAN {wan}
Marker {last}</div>
{buttons}
<form method="post" action="/m?type=ABORT" onsubmit="return send(event,'ABORT')">
<button class="abort" type="submit">⛔ LAST SOFORT STOPPEN</button></form>
<script>
// JS ist Komfort, keine Voraussetzung: die Buttons sind Formulare, die Anleitung kommt
// notfalls per <meta refresh>. Laeuft JS, ersetzen wir das durch leises Nachladen.
window.onerror = function(m, s, l, c){{
try{{ fetch('/jserr?msg=' + encodeURIComponent(m + ' @' + l + ':' + c), {{method:'POST'}}); }}catch(e){{}}
}};
function send(ev, name){{
try{{
ev.preventDefault();
fetch('/m?type=' + encodeURIComponent(name), {{method:'POST'}}).then(refresh);
var b = ev.target.querySelector('button');
if(b){{ b.style.filter='brightness(1.6)'; setTimeout(function(){{b.style.filter='';}},400); }}
return false;
}}catch(e){{ return true; }} // im Zweifel den normalen Form-POST durchlassen
}}
function refresh(){{
fetch('/').then(function(r){{return r.text();}}).then(function(html){{
var d = document.createElement('html'); d.innerHTML = html;
var n = d.querySelector('.card'), o = document.querySelector('.card');
if(n && o) o.replaceWith(n);
var nb = d.querySelector('.live'), ob = document.querySelector('.live');
if(nb && ob) ob.replaceWith(nb);
var nban = d.querySelector('.banner'), oban = document.querySelector('.banner');
if(nban && oban) oban.replaceWith(nban); else if(nban && !oban) document.body.prepend(nban);
tick();
}}).catch(function(){{}});
}}
function tick(){{
var el = document.getElementById('cd'); if(!el) return;
var ends = parseInt(el.dataset.ends || '0', 10); if(!ends) {{ el.textContent=''; return; }}
var r = Math.max(0, ends - Math.floor(Date.now()/1000));
el.textContent = r ? 'noch ~' + r + ' s' : 'gleich weiter …';
}}
var mr = document.getElementById('metaref'); if(mr) mr.remove(); // JS lebt -> kein Vollreload
setInterval(tick, 1000); setInterval(refresh, 3000);
// Bildschirm wachhalten: gesperrtes iPad = kein Not-Aus.
async function wake(){{ try{{ if('wakeLock' in navigator) await navigator.wakeLock.request('screen'); }}catch(e){{}} }}
document.addEventListener('visibilitychange', function(){{ if(!document.hidden) wake(); }});
wake();
</script>"""
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json"):
raw = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype + "; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def _back(self):
self.send_response(303)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
if self.path.startswith("/status"):
st = read_state()
st["sampler"] = sampler_tail()
st["last"] = last_marker()
self._send(200, json.dumps(st))
elif self.path in ("/", "/index.html"):
self._send(200, render(read_state()), "text/html")
else:
self._send(404, "{}")
def do_POST(self):
if self.path.startswith("/jserr"):
with open(os.path.join(RUN_DIR, "jserrors.log"), "a") as fh:
fh.write("%s %s\n" % (time.strftime("%H:%M:%S"), self.path.split("msg=", 1)[-1]))
self._send(200, "{}")
return
if self.path.startswith("/m?"):
name = self.path.split("type=", 1)[1].split("&")[0].upper()
if name == "ABORT":
write_marker("ABORT_killed=%d" % kill_flows())
else:
if name == "CALL_START":
open(GO_FLAG, "w").close()
write_marker(name)
if "text/html" in (self.headers.get("Accept") or ""):
self._back()
else:
self._send(200, json.dumps({"ok": True}))
else:
self._send(404, "{}")
def log_message(self, *_a):
pass
if __name__ == "__main__":
print("Konsole: http://localhost:%d/ · vom iPad: http://10.40.10.183:%d/" % (PORT, PORT))
print("Marker -> %s" % MARKERS)
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()