#!/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
-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 . 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 = ('') elif mode == "langzeit": banner = ('') prog = "" if st.get("steps"): prog = '
Schritt %d von %d
' % (st.get("step", 0), st["steps"]) countdown = ('
noch ~%d s
' % (st.get("ends", 0), rest) if rest else '
') 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( '' '
' % (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 = """ TP6 — Call-Konsole {banner}
{phase}

{title}

{instruction}
{countdown} {prog}
WLAN {wlan} WAN {wan} Marker {last}
{buttons}
""" 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()