Files
bf-infra/docs/tp6-marker/server.py
Christoph Emsenhuber 5231f7b69b AP-Pin fuer den Rundgang -- und drei Dinge, die dabei still falsch waren
TP25: das S22 laesst sich jetzt per access-list am Manager auf EINEN AP
festnageln (docs/tp25-pin-s22.sh, Umschalter in der Klick-Konsole). Damit
werden Einzel-AP-Abdeckungskarten messbar, statt dass der Client mitten im
Weg wechselt. Gepinnt wird der Standort, nicht das Band -- Band-Ping-Pong
(Fund C) bleibt damit ein echter Befund und wird nicht wegkonfiguriert.

Beim Bauen fielen drei Fehler auf, die alle dieselbe Form haben: eine
Konstante, die einmal richtig war.

1. Die AP-Zuordnung war VERTAUSCHT. server.py und tp25-gehweg-auswertung.py
   fuehren cap-wifi1/2 als ap1 -- live ist es ap2 (belegt ueber cap=,
   configuration= und Kanal). Baender stimmten, Identitaet nicht. Die
   cap-wifiN-Nummern vergibt der Manager dynamisch in der Anmeldereihenfolge
   der CAPs, jede feste Tabelle dazu wartet auf den naechsten AP-Reboot.
   server.py liest sie jetzt vom Geraet; die Auswertung ist NOCH NICHT
   angefasst -- offen ist, ob die Funde vom 08-21 mit vertauschten AP-Namen
   notiert sind.

2. Das Laufverzeichnis war beim Serverstart eingefroren. Die Konsole laeuft
   als Dauer-Preview, der Messlauf legt spaeter ein neues Verzeichnis an --
   Marker waeren in den Lauf von gestern gewandert, waehrend der Sampler ins
   neue schreibt. tp25-walk-messung.sh hinterlaesst jetzt einen Zeiger.

3. Das Startbanner nannte eine hartkodierte IP (10.40.10.183 = MacBook Air).
   Auf dem Mac mini ist es 10.40.10.82, und genau die falsche Adresse ging
   ans Telefon: "geht nicht" bei einem Server, der sauber auf 0.0.0.0 lauschte.
   Es werden jetzt die real gefundenen Interface-Adressen gedruckt.

Ausserdem, aus dem Test heraus:
- Ein vom Pin ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von
  einem Funkloch zu unterscheiden, und der Manager protokolliert ihn auf
  info-Ebene nicht. Android quittiert ihn mit "Verbindung fehlgeschlagen" und
  waehlt das Netz ab -- danach misst der Rest des Rundgangs Stille. Die Konsole
  schlaegt deshalb mit Vollbanner, Ton und Vibration Alarm, sobald das gepinnte
  Geraet nirgends haengt.
- Die Konsole laeuft diesmal auf dem S22 selbst, also auf genau dem Geraet, das
  der Pin gleich vom WLAN wirft. Die Anweisung zum Wiederverbinden erscheint
  deshalb clientseitig VOR dem Abbruch.
- ThreadingHTTPServer: der Pin braucht ~8 s ueber SSH und haette sonst die
  ganze Seite blockiert, Not-Aus inklusive.

Governance: /interface wifi access-list steht unter KEINER ComplianceRule --
der Change erzeugt keinen Drift und laesst sich nicht als Transaktion fuehren.
Fuer eine Messung vertretbar; relevant wird es, wenn Fund E (signal-range)
dauerhaft dort landet.

teilprojekte.md: die beiden Hinweise auf die offene Transaktion
TEST-ether3-trunk-ap1-sideboard nachgezogen -- sie ist seit 08-22
zurueckgerollt, crs326-core ist transaktionsfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:21:55 +02:00

606 lines
26 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.
🔴 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 = ('<div class="banner warn">📵 S22 HÄNGT NIRGENDS — Display an und wieder auf '
'dirtyvader-main verbinden. Der Pin wirft es vom gesperrten AP; Android '
'wählt das Netz dann von selbst ab. <b>Nicht weitergehen</b>, sonst misst '
'du Stille statt Abdeckung.</div>')
elif 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_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 <b>%s</b>" % ziel
elif ziel == "off":
pinstatus = "kein Pin — S22 roamt frei"
else:
pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)"
pinkarte = (
'<div class="card"><span id="sig" data-alarm="%d" data-busy="%d" data-pin="%s"></span>'
'<div class="phase">AP-Pin S22</div>'
'<div class="inst" id="pininst">%s</div><div class="raster">'
'<form method="post" action="/pin?ap=ap1" onsubmit="return sendpin(event,\'ap1\')">'
'<button class="raum%s" type="submit">ap1</button></form>'
'<form method="post" action="/pin?ap=ap2" onsubmit="return sendpin(event,\'ap2\')">'
'<button class="raum%s" type="submit">ap2</button></form>'
'</div>'
'<form method="post" action="/pin?ap=off" onsubmit="return sendpin(event,\'off\')">'
'<button class="cam" style="margin-top:8px" type="submit">Pin lösen</button></form>'
'</div>' % (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 = ("<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))
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,
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 = """<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}}
form{{margin:0}}
</style>
{banner}
<div class="card">
<div class="phase">{phase}</div>
<h2>{title}</h2>
<div class="inst">{instruction}</div>
{countdown}
{prog}
</div>
{pinkarte}
{raumzeile}
<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(); 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(); // Umschalten fertig
letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0;
}}
function sendpin(ev, ap){{
try{{
ev.preventDefault(); unlock();
// 🔴 Der Klick kommt vom S22 SELBST -- also von genau dem Geraet, das der Pin gleich vom
// WLAN wirft. Die Antwort auf dieses fetch() kommt deshalb womoeglich nie an, und die
// Seite kann sich nicht mehr nachladen. Die Anweisung muss JETZT auf dem Schirm
// stehen, solange die Verbindung noch steht.
var inst = document.getElementById('pininst');
if(inst) inst.innerHTML = '⏳ schalte auf <b>' + ap + '</b> …<br>' +
'<b>Dieses Telefon fliegt jetzt aus dem WLAN.</b> Wenn die Seite haengt: in den ' +
'WLAN-Einstellungen wieder auf <b>dirtyvader-main</b> verbinden, dann laedt sie von selbst.';
okton();
fetch('/pin?ap=' + encodeURIComponent(ap), {{method:'POST'}}).catch(function(){{}});
setTimeout(refresh, 3000); setTimeout(refresh, 9000); setTimeout(refresh, 20000);
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("/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()