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>
This commit is contained in:
2026-09-04 14:18:07 +02:00
parent efb0f44fa7
commit 70afb165b7
5 changed files with 396 additions and 315 deletions

View File

@@ -16,12 +16,12 @@ Zwei Aufgaben:
- 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
🔴 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 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.
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).
"""
@@ -164,6 +164,82 @@ def sampler_tail():
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
@@ -214,153 +290,104 @@ def ap_map():
return _apmap["karte"]
# 🔎 Die Messidee (Christoph, 2026-08-22): das iPhone roamt frei und zeigt damit, welchen AP
# ein normaler Client an dieser Stelle waehlt -- das wissen wir also ohnehin. Interessant
# ist der ANDERE. Deshalb wird das S22 automatisch auf den Gegen-AP gezwungen: pro Standort
# gibt es dann zwei Messpunkte statt einem, ohne zweimal zu gehen.
IPHONE_MAC = os.environ.get("TP25_IPHONE_MAC", "F2:62:4C:E1:AF:09")
S22_MAC = os.environ.get("TP25_PIN_MAC", "2A:0D:B0:5D:AD:A7")
# 🔎 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.
# Was der Beobachter sieht. "" = nicht angemeldet.
_live = {"iphone": "", "iphone_sig": "", "s22": "", "s22_sig": "", "stand": 0.0}
_auto = {"an": False, "seit_weg": 0.0, "aufgegeben": ""}
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 core_passwort():
"""Aus dem Credential-Store. Wird nie geloggt und nie in die Seite gerendert."""
with open(os.path.expanduser("~/.secrets/credentials.env")) as fh:
for zeile in fh:
if zeile.startswith("CRS326_CORE_PASS="):
return zeile.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("CRS326_CORE_PASS nicht gefunden")
def pin_targets():
"""Eindeutige (ap, band)-Paare aus der Live-Karte, band-normalisiert fuer tp25-pin-multi.sh.
def _ap_von(iface):
return ap_map().get(iface, "").split(" /")[0]
def beobachter():
"""Haelt EINE SSH-Sitzung offen und liest beide Telefone im 2-s-Takt.
🔴 Bewusst kein Poll mit je einem ssh-Aufruf: das waeren ueber einen Rundgang mehrere
hundert Verbindungsaufbauten, jeder ~1 s. Dasselbe Muster wie tp6-monitor-core.sh --
inklusive Reconnect-Schleife, weil die Sitzung am 2026-08-21 mitten in einer Messung
weggebrochen ist und die Daten still endeten.
ap_map() liefert 'apX / 5 GHz' bzw. 'apX / 2,4 GHz'; das Skript erwartet Band '5' oder '24'.
"""
# 🔴 KEINE Klammern um die :while-Schleife -- "(:while ...)" laesst RouterOS die
# Sitzung wortlos beenden, und mit stderr auf DEVNULL sieht man nur, dass alle 3 s
# ein neuer ssh startet. Genau so lag der Beobachter am 2026-08-22 tot.
ros = (':while (true) do={'
':local a [/interface/wifi/registration-table find where mac-address="%s"];'
':local b [/interface/wifi/registration-table find where mac-address="%s"];'
':local ai ""; :local as2 ""; :local bi ""; :local bs "";'
':if ([:len $a] > 0) do={:set ai [/interface/wifi/registration-table get [:pick $a 0] interface];'
':set as2 [/interface/wifi/registration-table get [:pick $a 0] signal]};'
':if ([:len $b] > 0) do={:set bi [/interface/wifi/registration-table get [:pick $b 0] interface];'
':set bs [/interface/wifi/registration-table get [:pick $b 0] signal]};'
':put ($ai . "," . $as2 . "," . $bi . "," . $bs); :delay 2s;}'
% (IPHONE_MAC, S22_MAC))
while True:
try:
pw = core_passwort()
# 🔴 sshpass -e statt -p: bei -p steht das Router-Passwort in der KOMMANDOZEILE
# und damit in `ps`, in jedem Prozess-Trace und in jeder Debug-Ausgabe, die
# argv anfasst. Am 2026-08-22 ist es genau so im Klartext in einem Trace
# gelandet. Mit -e liegt es nur in der Umgebung dieses einen Kindprozesses.
umgebung = dict(os.environ, SSHPASS=pw)
proc = subprocess.Popen(
["sshpass", "-e", "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15",
"admin@10.40.99.1", ros],
stdout=subprocess.PIPE, stderr=open(_p("beobachter.log"), "a"),
text=True, bufsize=1, env=umgebung)
# 🔴 NICHT `for zeile in proc.stdout`: die Iteration eines Pipes liest blockweise
# vor, die Zeilen kommen also erst an, wenn ~4 KB beisammen sind -- bei einer
# Zeile alle 2 s waere das eine Stunde. readline() liefert sofort.
for zeile in iter(proc.stdout.readline, ""):
teile = zeile.strip().split(",")
if len(teile) != 4:
continue
_live.update(iphone=teile[0], iphone_sig=teile[1],
s22=teile[2], s22_sig=teile[3], stand=time.time())
# 🔴 Bis 2026-08-22 blieben diese Zahlen NUR im Speicher, fuer die Anzeige.
# Nach dem Rundgang fehlte deshalb die halbe Messung: das S22 sitzt per
# Automatik immer auf dem Gegen-AP, die Alltagsabdeckung zeigt das iPhone
# -- und genau dessen Zeitreihe war weg. Nachtraeglich liess sich nur das
# Roaming-Log am Manager retten: Ereignisse, keine Kurve.
mitschreiben(teile)
entscheide()
except Exception as e:
# Ein Beobachter, der still stirbt, ist schlimmer als keiner (Lehre aus dem
# Sampler-Abbruch am 2026-08-21). Also mitschreiben statt schlucken.
try:
with open(_p("beobachter.log"), "a") as fh:
fh.write("%s %s: %s\n" % (time.strftime("%H:%M:%S"), type(e).__name__, e))
except Exception:
pass
time.sleep(3)
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 mitschreiben(teile):
"""Beide Telefone im 2-s-Takt in den Lauf schreiben: iface + Signal je Geraet."""
try:
pfad = _p("beide-telefone.csv")
frisch = not os.path.exists(pfad) or os.path.getsize(pfad) == 0
with open(pfad, "a") as fh:
if frisch:
fh.write("ts_epoch,ts_local,iphone_iface,iphone_sig,s22_iface,s22_sig\n")
fh.write("%d,%s,%s\n" % (time.time(), time.strftime("%H:%M:%S"), ",".join(teile)))
except Exception:
pass
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 entscheide():
"""Gegen-AP setzen, sobald das iPhone den AP wechselt."""
if not _auto["an"] or _pin["busy"]:
return
ip_ap = _ap_von(_live["iphone"])
if ip_ap not in ("ap1", "ap2"):
return # iPhone selbst nicht angemeldet -> nichts zu spiegeln
gegen = "ap1" if ip_ap == "ap2" else "ap2"
if pin_lesen() != gegen:
_auto["aufgegeben"] = ""
_auto["seit_weg"] = time.time()
pin_setzen(gegen)
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])
# 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]
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:
_pin["msg"] = "Fehler: %s" % e
_pinm["msg"] = "Fehler: %s" % e
finally:
_pin["busy"] = False
_pin["stand"] = 0.0
pin_lesen()
_pin["busy"] = True
_pin["msg"] = "schaltet um …"
_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 AP gehoeren.
write_marker("PIN=%s" % ziel)
# 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()
@@ -391,27 +418,42 @@ def fmt_bps(v):
def render(st):
w = sampler_tail()
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
# 🔴 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>')
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 '
'AP-Pin gehen trotzdem. Für die Messkurve <b>tp25-walk-messung.sh</b> starten.</div>')
'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>')
@@ -423,86 +465,65 @@ def render(st):
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")))
# 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))
ziel = pin_lesen()
ip_ap, s22_ap = _ap_von(_live["iphone"]), _ap_von(_live["s22"])
frisch = time.time() - _live["stand"] < 15
# Pin-UI: je Client ein Ziel-Dropdown, dazu setzen/loesen/verify.
ziele = pin_targets()
# Drei Zustaende, nicht zwei. "S22 kommt nicht rein" ist ein ERGEBNIS (der Gegen-AP
# traegt hier nicht), kein Defekt -- es darf deshalb nicht wie ein Fehler aussehen und
# nicht ewig Alarm schlagen.
# 🔴 "beide angemeldet" ist NICHT das Kriterium -- der Messpunkt taugt nur, wenn die
# beiden Telefone auf VERSCHIEDENEN APs sitzen. Am 2026-08-22 meldete die Seite
# "✅ beide angemeldet, auf verschiedenen APs", waehrend beide auf ap2 hingen: ein
# gruenes Licht fuer eine Messung, die es gar nicht gab. Wieder derselbe Fehler --
# die Anzeige geprueft statt der Wirkung.
getrennt = bool(ip_ap and s22_ap and ip_ap != s22_ap)
bereit = bool(frisch and getrennt)
gleich = bool(frisch and ip_ap and s22_ap and ip_ap == s22_ap)
wartet = bool(frisch and ip_ap and not s22_ap and not _auto["aufgegeben"])
if wartet and _auto["seit_weg"] and time.time() - _auto["seit_weg"] > 45:
_auto["aufgegeben"] = ziel or "?"
write_marker("UNERREICHBAR=%s" % (ziel or "?"))
wartet = False
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)
def z(ap, sig, mac_name):
if not frisch:
return "%s: —" % mac_name
return ("%s: <b>%s</b> %s dBm" % (mac_name, ap, sig)) if ap else ("%s: <b>nirgends</b>" % mac_name)
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"))))
doppel = "%s &nbsp;·&nbsp; %s" % (z(ip_ap, _live["iphone_sig"], "iPhone"),
z(s22_ap, _live["s22_sig"], "S22"))
if _pin["busy"]:
pinstatus = "⏳ %s<br>%s" % (_pin["msg"], doppel)
elif _auto["aufgegeben"]:
pinstatus = ("🚫 S22 bekommt <b>%s</b> hier nicht — <b>das ist das Messergebnis</b>, "
"Raum markieren und weitergehen.<br>%s" % (_auto["aufgegeben"], doppel))
elif bereit:
pinstatus = "✅ beide angemeldet, <b>verschiedene APs</b> — Messpunkt zählt<br>%s" % doppel
elif gleich and _auto["an"]:
pinstatus = ("⏳ beide auf <b>%s</b> — Gegen-AP wird gerade nachgezogen, "
"noch nicht weitergehen<br>%s" % (ip_ap, doppel))
elif gleich:
pinstatus = ("⚠️ beide auf <b>%s</b> — <b>kein Gegen-AP-Messpunkt.</b> "
"Automatik einschalten oder S22 von Hand pinnen.<br>%s" % (ip_ap, doppel))
elif wartet:
pinstatus = "⏳ S22 sucht <b>%s</b> …<br>%s" % (ziel or "?", doppel)
elif ziel in ("ap1", "ap2"):
pinstatus = "S22 gepinnt auf <b>%s</b><br>%s" % (ziel, doppel)
elif ziel == "off":
pinstatus = "kein Pin — S22 roamt frei<br>%s" % doppel
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 = "Zustand unbekannt (Manager nicht erreichbar?)"
pinkarte = (
'<div class="card"><span id="sig" data-alarm="%d" data-busy="%d" data-pin="%s" '
'data-bereit="%d"></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="/auto" onsubmit="return sendauto(event)">'
'<button class="%s" style="margin-top:8px" type="submit">%s</button></form>'
'<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 "?",
1 if bereit else 0,
pinstatus,
" hier" if ziel == "ap1" else "",
" hier" if ziel == "ap2" else "",
"ok" if _auto["an"] else "cam",
"🔁 Gegen-AP: AN (folgt dem iPhone)" if _auto["an"]
else "🔁 Gegen-AP automatisch",
)
)
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
@@ -547,8 +568,9 @@ def render(st):
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", "?")),
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,
)
@@ -579,6 +601,11 @@ button:active{{filter:brightness(1.3)}}
.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}
@@ -589,10 +616,10 @@ form{{margin:0}}
{countdown}
{prog}
</div>
{pinkarte}
{clients}
{pinm_karte}
{raumzeile}
<div class="live">WLAN {wlan}
WAN {wan}
<div class="live">WAN {wan}
Marker {last}</div>
{buttons}
{notaus}
@@ -638,18 +665,10 @@ function alarmton(){{ ton(880,180,0); ton(660,180,0.24); ton(880,260,0.48);
function okton(){{ ton(1320,90,0); ton(1760,120,0.11);
try{{ navigator.vibrate && navigator.vibrate(90); }}catch(e){{}} }}
function bereitton(){{ ton(1180,110,0); ton(1580,150,0.13);
try{{ navigator.vibrate && navigator.vibrate([60,60,60]); }}catch(e){{}} }}
var letzterAlarm = 0, letzterBusy = 0, letzterBereit = 0, alarmTimer = null;
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';
var bereit = sig.dataset.bereit === '1';
// Der Piep, auf den Christoph wartet: beide Telefone haengen, auf verschiedenen APs --
// der Messpunkt ist vollstaendig, weitergehen. Nur bei ZUSTANDSWECHSEL, nicht dauernd.
if(bereit && !letzterBereit) bereitton();
letzterBereit = bereit ? 1 : 0;
if(alarm && !letzterAlarm){{
alarmton();
// Solange nichts geschieht, alle 12 s erinnern -- ein einmaliger Piep in der Hosentasche
@@ -657,7 +676,7 @@ function signale(){{
if(!alarmTimer) alarmTimer = setInterval(alarmton, 12000);
}}
if(!alarm && alarmTimer){{ clearInterval(alarmTimer); alarmTimer = null; }}
if(!busy && letzterBusy) okton(); // Umschalten fertig
if(!busy && letzterBusy) okton(); // Pin-Aktion fertig
letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0;
}}
@@ -672,28 +691,22 @@ function sendstop(ev){{
return false;
}}catch(e){{ return true; }}
}}
function sendauto(ev){{
function sendpinm(ev){{
try{{
ev.preventDefault(); unlock(); okton();
fetch('/auto', {{method:'POST'}}).catch(function(){{}});
setTimeout(refresh, 400); setTimeout(refresh, 3000);
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 sendpin(ev, ap){{
function sendpinmoff(ev){{
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);
ev.preventDefault(); unlock(); okton();
fetch(ev.target.action, {{method:'POST'}}).catch(function(){{}});
setTimeout(refresh, 3000); setTimeout(refresh, 9000);
return false;
}}catch(e){{ return true; }}
}}
@@ -767,37 +780,27 @@ class Handler(BaseHTTPRequestHandler):
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("/auto"):
# Ohne Parameter umschalten (das macht der Knopf), mit ?an=0|1 gezielt setzen --
# tp25-runde.sh schaltet damit beim Start scharf. Ein Toggle waere dort falsch:
# laeuft die Konsole schon mit Automatik, haette der Start sie AUSgeschaltet.
if "an=" in self.path:
_auto["an"] = self.path.split("an=", 1)[1].split("&")[0] in ("1", "true", "an")
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:
_auto["an"] = not _auto["an"]
_auto["aufgegeben"] = ""
_auto["seit_weg"] = time.time()
write_marker("AUTO=%s" % ("an" if _auto["an"] else "aus"))
if _auto["an"]:
entscheide()
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, "auto": _auto["an"]}))
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}))
self._send(200, json.dumps({"ok": True}))
return
if self.path.startswith("/m?"):
# 🔴 Erst dekodieren, dann NICHT hochschreiben: "RAUM%3DDiele%20OG" wurde sonst zu
@@ -836,8 +839,7 @@ if __name__ == "__main__":
print("Marker -> %s" % markers_pfad())
for iface, wo in sorted(ap_map().items()):
print(" %-14s %s" % (iface, wo))
threading.Thread(target=beobachter, daemon=True).start()
# 🔴 Threading ist Pflicht, seit der Pin dazugekommen ist: das Umschalten redet ueber SSH
# 🔴 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()