#!/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. 🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der AP-Pin (TP25) schreibt access-list-Regeln auf den WLAN-Manager. Die Seite bindet auf 0.0.0.0 und kennt keine Authentifizierung — wer in VLAN10 steht, kann den Pin umschalten. Das ist fuer die Dauer einer Messung vertretbar und sonst nicht: den Server nach dem Rundgang beenden. Der Pin selbst ist harmlos genug (er betrifft eine MAC), aber die Regel ist es nicht, die als naechstes hier landen koennte. Bindet auf 0.0.0.0 (Bedienung vom iPhone/iPad). """ import json import os import signal import subprocess import threading import urllib.parse import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # 🔴 Das Laufverzeichnis wird NICHT mehr beim Start eingefroren. Grund (2026-08-22): die # Konsole laeuft als Dauer-Preview auf Port 3031, der Messlauf startet spaeter und legt # ein neues Verzeichnis an. Vorher schrieb der Server dann brav Marker in den Lauf von # GESTERN, waehrend der Sampler ins neue Verzeichnis schrieb -- die beiden Zeitachsen # waren hinterher nicht mehr zusammenzubringen, und man sah es der Seite nicht an. # Jetzt hinterlaesst tp25-walk-messung.sh einen Zeiger, dem die Konsole folgt. FALLBACK_RUN_DIR = ("/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/" "0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2") POINTER = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".current-run") def run_dir(): d = os.environ.get("TP6_RUN_DIR") if d: return d try: z = open(POINTER).read().strip() if z and os.path.isdir(z): return z except Exception: pass return FALLBACK_RUN_DIR def _p(name): return os.path.join(run_dir(), name) def sampler_pfad(): return os.environ.get("TP6_SAMPLER", _p("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()] def markers_pfad(): d = run_dir() os.makedirs(d, exist_ok=True) pfad = os.path.join(d, "markers.csv") if not os.path.exists(pfad): with open(pfad, "w") as fh: fh.write("ts_epoch,ts_local,marker\n") return pfad # 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(_p("phase.json")) as fh: st = json.load(fh) st["driver"] = (time.time() - os.path.getmtime(_p("phase.json"))) < 20 return st except Exception: st = dict(DEFAULT_STATE) st["driver"] = False return st def write_marker(name): with open(markers_pfad(), "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_pfad()) 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_pfad()) 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_pfad()) 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(_p("flows.pid")).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(_p("abort.flag"), "w").close() return killed # 🔴 Hier stand bis 2026-08-22 eine fest verdrahtete Tabelle — und zwar mit VERTAUSCHTEN # APs (cap-wifi1/2 als ap1 statt ap2). Die Baender stimmten, die Identitaet nicht. Die # cap-wifiN-Nummern vergibt der Manager dynamisch in der Anmeldereihenfolge der CAPs; # jede Konstante dazu ist ein Fehler, der auf den naechsten AP-Reboot wartet und dann # still falsch beschriftet. Deshalb kommt die Zuordnung jetzt vom Geraet. PIN_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tp25-pin-s22.sh") _apmap = {"stand": 0.0, "karte": {}} _aplock = threading.Lock() def ap_map(): """cap-wifiN -> 'ap1 / 5 GHz'. Live vom Manager, 60 s gecacht.""" with _aplock: if time.time() - _apmap["stand"] < 60 and _apmap["karte"]: return _apmap["karte"] karte = {} try: out = subprocess.run(["bash", PIN_SH, "map"], capture_output=True, text=True, timeout=20).stdout for zeile in out.splitlines(): if "=" not in zeile: continue iface, wert = zeile.strip().split("=", 1) ap, _, band = wert.partition("/") karte[iface] = "%s / %s" % (ap, band.replace("GHz", " GHz")) except Exception: pass with _aplock: if karte: _apmap.update(stand=time.time(), karte=karte) return _apmap["karte"] # Pin-Zustand. Das Umschalten dauert ~8 s (Regeln + Kick + Nachlesen), deshalb laeuft es # in einem Thread und die Seite zeigt solange "schaltet um". _pin = {"ziel": None, "busy": False, "msg": "", "stand": 0.0} def pin_lesen(): if _pin["busy"] or time.time() - _pin["stand"] < 20: return _pin["ziel"] try: _pin["ziel"] = subprocess.run(["bash", PIN_SH, "which"], capture_output=True, text=True, timeout=20).stdout.strip() or None _pin["stand"] = time.time() except Exception: pass return _pin["ziel"] def pin_setzen(ziel): def lauf(): try: r = subprocess.run(["bash", PIN_SH, ziel], capture_output=True, text=True, timeout=60) _pin["msg"] = (r.stdout.strip().splitlines() or [""])[0] except Exception as e: _pin["msg"] = "Fehler: %s" % e finally: _pin["busy"] = False _pin["stand"] = 0.0 pin_lesen() _pin["busy"] = True _pin["msg"] = "schaltet um …" # Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher # nicht sagen, welche Messpunkte zu welchem AP gehoeren. write_marker("PIN=%s" % ziel) threading.Thread(target=lauf, daemon=True).start() def lokale_adressen(): """Alle IPv4-Adressen dieses Rechners ausser Loopback/Link-Local, als (iface, ip).""" treffer = [] try: out = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=10).stdout iface = "?" for zeile in out.splitlines(): if zeile and not zeile[0].isspace(): iface = zeile.split(":", 1)[0] elif zeile.strip().startswith("inet "): ip = zeile.split()[1] if not ip.startswith(("127.", "169.254.")): treffer.append((iface, ip)) except Exception: pass return treffer 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 = "" alarm = False # nur DIESER Zustand loest Ton + Vibration aus, nicht jeder Banner # 🔴 Der wichtigste Alarm der Seite, seit es den AP-Pin gibt (2026-08-22): ein vom Pin # ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von einem Funkloch zu # unterscheiden -- und der Manager protokolliert die Abweisung auf info-Ebene nicht. # Android quittiert sie mit "Verbindung fehlgeschlagen" und WAEHLT DAS NETZ AB, danach # misst der restliche Rundgang nur noch Stille. Deshalb wird "haengt nirgends" hier # nicht klein in der WLAN-Zeile gezeigt, sondern schreit. if pin_lesen() in ("ap1", "ap2") and w.get("iface") == "absent" and w.get("age", 999) < 30: alarm = True banner = ('') elif 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_map().get(w["iface"], w["iface"]), w.get("signal", "?"), fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps"))) ziel = pin_lesen() if _pin["busy"]: pinstatus = "⏳ %s" % _pin["msg"] elif ziel in ("ap1", "ap2"): pinstatus = "S22 haengt fest auf %s" % ziel elif ziel == "off": pinstatus = "kein Pin — S22 roamt frei" else: pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)" pinkarte = ( '
' '
AP-Pin S22
' '
%s
' '' '' '
' '
' '
' '
' '
' '
' % (1 if alarm else 0, 1 if _pin["busy"] else 0, ziel or "?", pinstatus, " hier" if ziel == "ap1" else "", " hier" if ziel == "ap2" else "") ) 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, pinkarte=pinkarte, 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}
{pinkarte} {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(_p("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("/pin?"): ap = urllib.parse.unquote(self.path.split("ap=", 1)[1].split("&")[0]) if ap not in ("ap1", "ap2", "off"): self._send(400, json.dumps({"error": "ap muss ap1, ap2 oder off sein"})) return if _pin["busy"]: self._send(409, json.dumps({"error": "laeuft schon"})) return pin_setzen(ap) if "text/html" in (self.headers.get("Accept") or ""): self._back() else: self._send(200, json.dumps({"ok": True, "ziel": ap})) 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(_p("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__": # 🔴 Hier stand eine HARTKODIERTE IP (10.40.10.183) -- die des MacBook Air. Auf dem Mac # mini ist es 10.40.10.82, und am 2026-08-22 wurde die falsche Adresse ans Telefon # weitergegeben: "geht nicht" bei einem Server, der einwandfrei auf 0.0.0.0 lauschte. # Eine Adresse, die man ansagt, ohne sie gelesen zu haben, ist keine Adresse. # Deshalb kommt sie jetzt vom Interface -- und zwar ALLE, mit Kennzeichnung. print("Konsole lokal: http://localhost:%d/" % PORT) for name, ip in lokale_adressen(): print(" vom Telefon: http://%s:%d/ (%s)" % (ip, PORT, name)) if not lokale_adressen(): print(" ⚠️ keine LAN-Adresse gefunden -- vom Telefon nicht erreichbar") print("Marker -> %s" % markers_pfad()) for iface, wo in sorted(ap_map().items()): print(" %-14s %s" % (iface, wo)) # 🔴 Threading ist Pflicht, seit der Pin dazugekommen ist: das Umschalten redet ueber SSH # mit dem Manager und braucht ~8 s. Mit dem alten HTTPServer (single-threaded) haette # das die ganze Konsole blockiert -- inklusive Not-Aus. ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()