Files
bf-infra/docs/tp6-marker/server.py
Christoph Emsenhuber 70afb165b7 TP25: Klick-Konsole auf N Clients + Pin-UI
Die Konsole (docs/tp6-marker/server.py) war beim Multi-Pin-Umbau nicht
mitgezogen worden: hartkodiert auf 2 Clients, pinnte ueber die alte
Gegen-AP-Automatik. Jetzt:

- Client-Liste aus geraete.txt (schreibt tp25-walk-messung.sh), Fallback
  auf die zwei alten MACs fuer Alt-Laeufe.
- Kein eigenes SSH mehr: je Client letzte Zeile von sampler-<name>.csv
  (AP/Signal) + Ping-Verlust aus walk-ping-<name>.csv.
- N Client-Karten; Alarm, wenn ein gepinnter Client nirgends haengt.
- Pin-UI: je Client AP/Band waehlen, set/off/verify via tp25-pin-multi.sh
  (POST /pinmulti). Alte 2-Client-Automatik (entscheide/pin_setzen/auto)
  entfernt; tp25-pin-s22.sh liefert nur noch die map.
- tp25-runde.sh loest beim Aufraeumen den Multi-Pin statt des S22-Pins.

Doku: Ist-Stand-Block + Quick-Index + Messwerkzeug-Bullets nachgezogen,
neuer Handoff 2026-09-04.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-04 14:18:07 +02:00

846 lines
36 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der Multi-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 pinnen. 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 MACs), 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 geraete():
"""Liste (name, ip, mac) aus geraete.txt im Laufverzeichnis.
🔴 Die Datei schreibt tp25-walk-messung.sh beim Start des Messlaufs (eine Zeile
name|ip|mac je Client). Fehlt sie — Alt-Lauf oder Dauer-Preview, die schon vor dem
ersten Lauf lief — fallen wir auf die zwei bisherigen Geraete zurueck, damit die
Anzeige nicht leer ist. Die Namen muessen zu den sampler-<name>.csv /
walk-ping-<name>.csv passen, die die Harness schreibt.
"""
try:
zeilen = [z.strip() for z in open(_p("geraete.txt")) if z.strip()]
gs = []
for z in zeilen:
teile = z.split("|")
if len(teile) >= 3 and teile[0]:
gs.append((teile[0], teile[1], teile[2]))
if gs:
return gs
except Exception:
pass
return [("iphone", "", "F2:62:4C:E1:AF:09"), ("s22", "", "2A:0D:B0:5D:AD:A7")]
def client_tail(name):
"""Letzte Sampler-Zeile EINES Geraets (sampler-<name>.csv). Fehlt sie, ist das kein Fehler.
Gleiche Spalten wie sampler_tail() — die Harness schreibt pro Geraet einen eigenen
Sampler mit demselben Header (tp6-monitor-core.sh). Die Konsole braucht deshalb KEINE
eigene SSH-Sitzung mehr: sie liest nur, was die Sampler schon auf die Platte legen.
"""
try:
pfad = _p("sampler-%s.csv" % name)
rows = [r.strip() for r in open(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 ping_loss(name, n=150):
"""Ping-Verlust der letzten ~n Zeilen von walk-ping-<name>.csv (5 Pings/s -> n=150 ~ 30 s).
Leeres ms-Feld = Verlust. Liefert (verloren, gesamt) oder None, wenn noch keine Daten da
sind. Der Header (ts_local,seq,ms) wird ueber die nicht-numerische seq-Spalte uebersprungen.
"""
try:
rows = [r.strip() for r in open(_p("walk-ping-%s.csv" % name)) if r.strip()]
verloren = gesamt = 0
for z in rows[-n:]:
teile = z.split(",")
if len(teile) >= 3 and teile[1].isdigit():
gesamt += 1
if not teile[2].strip():
verloren += 1
return (verloren, gesamt) if gesamt else None
except Exception:
return None
def wan_tail():
"""WAN/CPU-Anzeige: aus sampler.csv (Alt-Lauf) oder dem ersten Geraet-Sampler."""
w = sampler_tail()
if w.get("cpu") or w.get("wan_rx"):
return w
gs = geraete()
return client_tail(gs[0][0]) if gs else {}
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"]
# 🔎 Die Messidee (Christoph, 2026-08-22, generalisiert 2026-09-04): die Clients roamen frei
# und zeigen damit, welchen AP ein normaler Client an dieser Stelle waehlt. Fuer die
# Abdeckungsfrage wird je Client per access-list GENAU EIN AP+Band erlaubt
# (tp25-pin-multi.sh). Die alte Gegen-AP-Automatik (S22 folgt dem iPhone) ist damit
# ueberholt: gepinnt wird vorab band-genau, nicht mehr automatisch waehrend des Gehens.
PINM_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tp25-pin-multi.sh")
# Pin-Zustand. Das Setzen dauert ~8 s (Regeln + Kick), deshalb laeuft es in einem Thread und
# die Seite zeigt solange "setze Pin …". pins = {name: "ap|band"} der zuletzt gesetzten Auswahl.
_pinm = {"busy": False, "msg": "", "pins": {}, "stand": 0.0}
def pin_targets():
"""Eindeutige (ap, band)-Paare aus der Live-Karte, band-normalisiert fuer tp25-pin-multi.sh.
ap_map() liefert 'apX / 5 GHz' bzw. 'apX / 2,4 GHz'; das Skript erwartet Band '5' oder '24'.
"""
ziele, gesehen = [], set()
for wert in ap_map().values():
ap, _, band = wert.partition(" / ")
band = band.replace(" GHz", "").replace(",", ".")
if band.startswith("5"):
band = "5"
elif band.startswith("2"):
band = "24"
else:
continue
if (ap, band) not in gesehen:
gesehen.add((ap, band))
ziele.append((ap, band))
return sorted(ziele)
def build_spec(form):
"""Aus den Formular-Auswahlen (pin_<name>=ap|band oder frei) den TP25_PINSPEC-String bauen.
Clients ohne Ziel (frei) bleiben frei — tp25-pin-multi.sh set raeumt vorher alle
TP25-PINM-*-Regeln ab und setzt nur die hier genannten.
"""
eintraege = []
for name, _ip, mac in geraete():
wert = (form.get("pin_%s" % name) or ["frei"])[0]
if wert and wert != "frei" and "|" in wert:
ap, band = wert.split("|", 1)
eintraege.append("%s|%s|%s|%s" % (name, mac, ap, band))
return ";".join(eintraege)
def pinm_aktion(spec):
"""Pin setzen (spec = 'name|mac|ap|band;...') oder loesen (spec = ''). Laeuft im Thread."""
pins = {}
for e in (spec.split(";") if spec else []):
teile = e.split("|")
if len(teile) >= 4:
pins[teile[0]] = "%s|%s" % (teile[2], teile[3])
def lauf():
try:
umgebung = dict(os.environ)
if spec:
umgebung["TP25_PINSPEC"] = spec
r = subprocess.run(["bash", PINM_SH, "set"], capture_output=True, text=True,
timeout=90, env=umgebung)
else:
r = subprocess.run(["bash", PINM_SH, "off"], capture_output=True, text=True,
timeout=90)
_pinm["msg"] = r.stdout.strip() or r.stderr.strip() or "ok"
except Exception as e:
_pinm["msg"] = "Fehler: %s" % e
finally:
_pinm["busy"] = False
_pinm["stand"] = time.time()
_pinm["busy"] = True
_pinm["msg"] = "setze Pin …" if spec else "löse Pin …"
_pinm["pins"] = pins
# Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher
# nicht sagen, welche Messpunkte zu welchem Pin gehoeren.
write_marker("PINM=%s" % (spec or "off"))
threading.Thread(target=lauf, daemon=True).start()
def pinm_verify():
"""tp25-pin-multi.sh verify im Thread — zeigt Regeln + aktuelle Anmeldungen."""
def lauf():
try:
r = subprocess.run(["bash", PINM_SH, "verify"], capture_output=True, text=True,
timeout=60)
_pinm["msg"] = r.stdout.strip() or r.stderr.strip() or "ok"
except Exception as e:
_pinm["msg"] = "Fehler: %s" % e
finally:
_pinm["busy"] = False
_pinm["stand"] = time.time()
_pinm["busy"] = True
_pinm["msg"] = "prüfe …"
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 = wan_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
haengende = []
# 🔴 Der wichtigste Alarm der Seite, seit es den Pin gibt (2026-08-22, generalisiert
# 2026-09-04): 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 Client-Zeile gezeigt, sondern schreit.
clients = []
for name, ip, mac in geraete():
t = client_tail(name)
pl = ping_loss(name)
iface = t.get("iface", "")
ap = ap_map().get(iface, iface) if iface else ""
signal = t.get("signal", "")
frisch = t.get("age", 999) < 30
gepinnt = name in _pinm["pins"]
if gepinnt and iface == "absent" and frisch:
alarm = True
haengende.append(name)
clients.append((name, ip, mac, iface, ap, signal, pl, frisch, gepinnt))
if haengende:
banner = ('<div class="banner warn">📵 <b>%s</b> HÄNGT NIRGENDS — der Pin wirft das '
'Geraet vom gesperrten AP; Android waehlt das Netz dann von selbst ab. '
'<b>Nicht weitergehen</b>, sonst misst du Stille statt Abdeckung.</div>'
% ", ".join(haengende))
elif not st.get("driver"):
banner = ('<div class="banner done">ℹ️ Keine Ping-Aufzeichnung — Räume markieren und '
'pinnen geht trotzdem. Für die Messkurve <b>tp25-walk-messung.sh</b> starten.</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>')
# Client-Karten: je Geraet Name, AP+Signal, Ping-Verlust der letzten ~30 s.
karten = ""
for name, ip, mac, iface, ap, signal, pl, frisch, gepinnt in clients:
if iface and iface != "absent":
status = ("%s · %s dBm" % (ap, signal)) if signal else ap
elif iface == "absent":
status = "<b>nirgends</b>"
else:
status = "—"
if pl:
verloren, gesamt = pl
ping = "%d/%d verloren" % (verloren, gesamt)
else:
ping = "—"
karten += ('<div class="card"><div class="phase">%s</div>'
'<div class="inst"><b>%s</b></div>'
'<div class="cval">%s</div>'
'<div class="cval">Ping: %s</div></div>'
% ("📌 gepinnt" if gepinnt else "Client", name, status, ping))
# Pin-UI: je Client ein Ziel-Dropdown, dazu setzen/loesen/verify.
ziele = pin_targets()
def optionen(aktuell):
out = ['<option value="frei"%s>frei</option>' % (" selected" if aktuell == "frei" else "")]
for ap, band in ziele:
wert = "%s|%s" % (ap, band)
label = "%s / %s GHz" % (ap, "5" if band == "5" else "2,4")
out.append('<option value="%s"%s>%s</option>'
% (wert, " selected" if wert == aktuell else "", label))
return "".join(out)
zeilen = ""
for name, ip, mac in geraete():
zeilen += ('<div class="pinzeile"><label>%s</label>'
'<select name="pin_%s">%s</select></div>'
% (name, name, optionen(_pinm["pins"].get(name, "frei"))))
if _pinm["busy"]:
pinstatus = "⏳ %s" % _pinm["msg"].replace("\n", "<br>")
elif _pinm["msg"]:
pinstatus = _pinm["msg"].replace("\n", "<br>")
elif not ziele:
pinstatus = "Manager nicht erreichbar — keine Pin-Ziele."
else:
pinstatus = "Je Client AP/Band wählen, dann „Pin setzen“."
pinm_karte = (
'<div class="card"><span id="sig" data-alarm="%d" data-busy="%d"></span>'
'<div class="phase">Pinnen (Multi-Pin)</div>'
'<div class="inst" id="pininst">%s</div>'
'<form method="post" action="/pinmulti" onsubmit="return sendpinm(event)">'
'%s'
'<button class="ok" type="submit">📌 Pin setzen</button></form>'
'<form method="post" action="/pinmulti?off=1" onsubmit="return sendpinmoff(event)">'
'<button class="cam" type="submit">Pin lösen</button></form>'
'<form method="post" action="/pinmulti?verify=1" onsubmit="return sendpinmoff(event)">'
'<button class="cam" type="submit">verify</button></form>'
'</div>' % (1 if alarm else 0, 1 if _pinm["busy"] else 0, pinstatus, zeilen))
raum, seit = current_room()
# 🔴 Die Kacheln hingen bis 2026-08-22 am Treiber-Zustand -- ohne laufenden TP6-Treiber
# waren sie unsichtbar, obwohl Raumkartierung genau das ist, was hier gebraucht wird.
# Kartieren geht immer; der Treiber ist dafuer irrelevant.
raumzeile = ""
if True:
aktuell = ("<b>%s</b> · seit %d:%02d" % (raum, seit // 60, seit % 60)) if raum else "— noch kein Raum gewählt —"
kacheln = "".join(
'<form method="post" action="/m?type=RAUM%%3D%s" onsubmit="return send(event,\'RAUM=%s\')">'
'<button class="raum%s" type="submit">%s</button></form>'
% (r.replace(" ", "%20"), r, " hier" if r == raum else "", r) for r in ROOMS)
raumzeile = ('<div class="card"><div class="phase">Wo bist du gerade?</div>'
'<div class="inst">%s</div><div class="raster">%s</div></div>' % (aktuell, kacheln))
# Die Call-Buttons (Start / Not-Aus fuer die Saettigungs-Flows) gehoeren zu TP6 und haben
# ohne Treiber keine Wirkung -- ein Start-Klick haette dort NICHTS gemessen. Sichtbar
# also nur, wenn wirklich ein Treiber laeuft.
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
) if st.get("driver") else ""
notaus = ('<form method="post" action="/m?type=ABORT" onsubmit="return send(event,\'ABORT\')">'
'<button class="abort" type="submit">⛔ LAST SOFORT STOPPEN</button></form>'
) if st.get("driver") else ""
# 🔴 Der Stopp-Knopf. Er beendet die AUFZEICHNUNG (stop.flag -> tp25-walk-messung.sh),
# nicht nur eine Marke. Vorher gab es ihn nicht, obwohl der Anleitungstext ihn nannte:
# am 2026-08-22 drueckte Christoph "Call beendet" und den Not-Aus, beide schrieben nur
# Marken, und der Core pingte danach noch minutenlang weiter.
if st.get("driver"):
notaus = ('<form method="post" action="/stop" onsubmit="return sendstop(event)">'
'<button class="end" type="submit">⏹ Aufzeichnung stoppen</button></form>'
) + notaus
# 🚶 Gehen/Stehen braucht keinen Treiber -- das sind reine Zeitmarken fuer die Auswertung.
btns = ('<form method="post" action="/m?type=WALK" onsubmit="return send(event,\'WALK\')">'
'<button class="walk" type="submit">🚶 Ich gehe jetzt los</button></form>'
'<form method="post" action="/m?type=ARRIVED" onsubmit="return send(event,\'ARRIVED\')">'
'<button class="walk" type="submit">📍 Ich stehe still</button></form>') + btns
return PAGE.format(
banner=banner, title=st.get("title", "—"), phase=st.get("phase", "—"),
instruction=st.get("instruction", ""), prog=prog, countdown=countdown,
raumzeile=raumzeile, clients=karten, pinm_karte=pinm_karte,
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, notaus=notaus,
)
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}}
.raster{{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}}
.raster form{{margin:0}}
.raum{{margin:0;padding:16px 8px;font-size:16px;background:#1e3a5f}}
.raum.hier{{background:#0ea5e9;color:#06202e;box-shadow:0 0 0 3px #7dd3fc inset}}
.cval{{font:14px/1.6 ui-monospace,Menlo,monospace;color:#9aa4b2;margin-top:2px}}
.pinzeile{{display:flex;align-items:center;gap:10px;margin:8px 0}}
.pinzeile label{{flex:0 0 90px;font-weight:600}}
.pinzeile select{{flex:1;padding:10px;font-size:16px;background:#262b34;color:#e8eaed;
border:1px solid #2a2f3a;border-radius:8px}}
form{{margin:0}}
</style>
{banner}
<div class="card">
<div class="phase">{phase}</div>
<h2>{title}</h2>
<div class="inst">{instruction}</div>
{countdown}
{prog}
</div>
{clients}
{pinm_karte}
{raumzeile}
<div class="live">WAN {wan}
Marker {last}</div>
{buttons}
{notaus}
<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(); unlock();
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
}}
// 🔊 Signalton. Browser lassen Audio erst nach einer echten Nutzergeste zu -- der erste
// Button-Druck schaltet ihn frei, deshalb haengt unlock() an jedem Klick. Auf dem S22
// kommt Vibration dazu; iOS kennt navigator.vibrate nicht, das faellt still aus.
var AC = null;
function unlock(){{
try{{
if(!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
if(AC.state === 'suspended') AC.resume();
}}catch(e){{}}
}}
function ton(hz, ms, wann){{
try{{
if(!AC) return;
var o = AC.createOscillator(), g = AC.createGain();
o.type = 'sine'; o.frequency.value = hz;
g.gain.setValueAtTime(0.001, AC.currentTime + wann);
g.gain.exponentialRampToValueAtTime(0.35, AC.currentTime + wann + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + wann + ms/1000);
o.connect(g); g.connect(AC.destination);
o.start(AC.currentTime + wann); o.stop(AC.currentTime + wann + ms/1000 + 0.05);
}}catch(e){{}}
}}
function alarmton(){{ ton(880,180,0); ton(660,180,0.24); ton(880,260,0.48);
try{{ navigator.vibrate && navigator.vibrate([200,120,200,120,320]); }}catch(e){{}} }}
function okton(){{ ton(1320,90,0); ton(1760,120,0.11);
try{{ navigator.vibrate && navigator.vibrate(90); }}catch(e){{}} }}
var letzterAlarm = 0, letzterBusy = 0, alarmTimer = null;
function signale(){{
var sig = document.getElementById('sig'); if(!sig) return;
var alarm = sig.dataset.alarm === '1', busy = sig.dataset.busy === '1';
if(alarm && !letzterAlarm){{
alarmton();
// Solange nichts geschieht, alle 12 s erinnern -- ein einmaliger Piep in der Hosentasche
// waehrend des Gehens ist kein Alarm.
if(!alarmTimer) alarmTimer = setInterval(alarmton, 12000);
}}
if(!alarm && alarmTimer){{ clearInterval(alarmTimer); alarmTimer = null; }}
if(!busy && letzterBusy) okton(); // Pin-Aktion fertig
letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0;
}}
function sendstop(ev){{
try{{
ev.preventDefault(); unlock(); okton();
var inst = document.getElementById('pininst');
fetch('/stop', {{method:'POST'}}).catch(function(){{}});
var b = ev.target.querySelector('button');
if(b){{ b.textContent = '⏹ gestoppt — Aufzeichnung endet'; b.disabled = true; }}
setTimeout(refresh, 1500);
return false;
}}catch(e){{ return true; }}
}}
function sendpinm(ev){{
try{{
ev.preventDefault(); unlock(); okton();
var inst = document.getElementById('pininst');
if(inst) inst.innerHTML = '⏳ setze Pin …<br>Gepinnte Geräte fliegen kurz aus dem WLAN und verbinden sich neu.';
var fd = new FormData(ev.target);
fetch('/pinmulti', {{method:'POST', body: new URLSearchParams(fd)}}).catch(function(){{}});
setTimeout(refresh, 3000); setTimeout(refresh, 9000); setTimeout(refresh, 20000);
return false;
}}catch(e){{ return true; }}
}}
function sendpinmoff(ev){{
try{{
ev.preventDefault(); unlock(); okton();
fetch(ev.target.action, {{method:'POST'}}).catch(function(){{}});
setTimeout(refresh, 3000); setTimeout(refresh, 9000);
return false;
}}catch(e){{ return true; }}
}}
function refresh(){{
fetch('/').then(function(r){{return r.text();}}).then(function(html){{
var d = document.createElement('html'); d.innerHTML = html;
var karten = d.querySelectorAll('.card'), alte = document.querySelectorAll('.card');
for(var i = 0; i < karten.length && i < alte.length; i++) alte[i].replaceWith(karten[i]);
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);
if(!nban && oban) oban.remove();
tick(); signale();
}}).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);
document.addEventListener('click', unlock, {{once:false}});
signale();
// 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(_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("/stop"):
# Der Knopf, den der Anleitungstext seit jeher versprach und den es nie gab.
open(_p("stop.flag"), "w").close()
write_marker("STOP")
self._send(200, json.dumps({"ok": True})) if "text/html" not in (
self.headers.get("Accept") or "") else self._back()
return
if self.path.startswith("/pinmulti"):
if _pinm["busy"]:
if "text/html" in (self.headers.get("Accept") or ""):
self._back()
else:
self._send(409, json.dumps({"error": "laeuft schon"}))
return
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if qs.get("off"):
pinm_aktion("")
elif qs.get("verify"):
pinm_verify()
else:
laenge = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(laenge).decode("utf-8") if laenge else ""
form = urllib.parse.parse_qs(body)
pinm_aktion(build_spec(form))
if "text/html" in (self.headers.get("Accept") or ""):
self._back()
else:
self._send(200, json.dumps({"ok": True}))
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 Setzen 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()