#!/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 urllib.parse 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")) # Raeume fuer die Gehweg-/Dead-Spot-Kartierung. Reihenfolge = Reihenfolge auf der Seite. # Aenderbar ohne Codeeingriff: TP25_ROOMS="Kueche,Bad,..." python3 docs/tp6-marker/server.py ROOMS = [r.strip() for r in os.environ.get( "TP25_ROOMS", "Küche,Wohnzimmer,Diele UG,Technik UG,Treppe,Kinderzimmer,Schlafzimmer,Diele OG,Arbeitszimmer,Bad,Klo,Technik OG,Terrasse" ).split(",") if r.strip()] 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 current_room(): """Letzte Raum-Marke plus Verweildauer — damit auf der Seite steht, was mitgeschrieben wird.""" try: for row in reversed([r.strip() for r in open(MARKERS) if r.strip()]): p = row.split(",") if len(p) >= 3 and p[2].startswith("RAUM="): return p[2][5:], int(time.time() - int(p[0])) except Exception: pass return None, 0 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"))) raum, seit = current_room() raumzeile = "" if mode in ("langzeit", "messung"): aktuell = ("%s · seit %d:%02d" % (raum, seit // 60, seit % 60)) if raum else "— noch kein Raum gewählt —" kacheln = "".join( '' '
' % (r.replace(" ", "%20"), r, " hier" if r == raum else "", r) for r in ROOMS) raumzeile = ('
Wo bist du gerade?
' '
%s
%s
' % (aktuell, kacheln)) 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, raumzeile=raumzeile, 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}
{raumzeile}
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?"): # 🔴 Erst dekodieren, dann NICHT hochschreiben: "RAUM%3DDiele%20OG" wurde sonst zu # "RAUM%3DDIELE%20OG" — der Marker war weder lesbar noch als Raum erkennbar. name = urllib.parse.unquote(self.path.split("type=", 1)[1].split("&")[0]) if not name.startswith("RAUM="): name = name.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()