Die Auswertung liest jetzt beide Geraete und stellt sie je Raum untereinander: REFERENZ (iPhone, frei) was du an dieser Stelle wirklich hast SONDE (S22, Gegen-AP) was die Rueckfallebene noch leisten koennte Damit faellt der Terrassen-Vergleich beim naechsten Lauf von selbst raus, und die Lesart wird eindeutig: gute Referenz + schlechte Sonde = alles haengt an EINEM AP; beide schlecht = echtes Loch ohne Rueckfallebene (der Kuechenfall). Rueckwaertskompatibel: Laeufe ohne walk-ping-iphone.csv/beide-telefone.csv laufen einspaltig weiter und liefern fuer den Lauf vom 22.8. Ziffer fuer Ziffer dieselben Zahlen wie vorher. Das Geraet heisst dort "Geraet" -- welches es war, steht in den Dateien nicht, also wird es auch nicht behauptet. Drei Fehler, die der Abnahmetest zutage foerderte und die alle den naechsten Rundgang gekostet haetten: 1. Die Gegen-AP-Automatik war nach dem Start AUS. Wer den Klick vergisst, geht die ganze Runde mit beiden Telefonen auf DEMSELBEN AP -- im Test genau so passiert. tp25-runde.sh schaltet sie jetzt scharf; /auto nimmt dafuer ?an=1 (ein Toggle waere falsch: bei schon laufender Konsole haette der Start sie ausgeschaltet). 2. "Funk da, aber keine einzige Ping-Antwort" erschien als "—", also wie eine Datenluecke. Beim Test lag das iPhone gesperrt auf dem Tisch: angemeldet mit -47 dBm und trotzdem stumm. Wer das als Luecke liest, sucht danach ein Funkloch, das es nicht gibt. Jetzt: "keine Antwort" + "⚠️ Display gesperrt?". 3. Der Aufraeumer meldete "AP-Pin geloest", und danach standen die Regeln trotzdem am Core. Wettlauf: er loeste den Pin, waehrend die Konsole noch lief, und deren Automatik setzte ihn binnen zwei Sekunden wieder. Das Telefon waere nach JEDER Runde gepinnt geblieben. Jetzt erst Konsole toeten, dann Pin loesen. Zuletzt: tp25-pin-s22.sh laeuft auf sshpass -e. Damit steht das Router-Passwort in keiner Kommandozeile mehr -- `grep -rn "sshpass -p" docs/ nautobot/` findet nur noch die Erwaehnung in teilprojekte.md. Die Rotation kann jetzt kommen. Abnahme (mehrere echte Laeufe): Automatik meldet EIN, beide Telefone stehen auf verschiedenen APs, beide werden gepingt, ein Klick auf Stopp beendet alles, Pin danach "off", kein Prozess uebrig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
844 lines
39 KiB
Python
844 lines
39 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"]
|
||
|
||
|
||
# 🔎 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")
|
||
|
||
# Was der Beobachter sieht. "" = nicht angemeldet.
|
||
_live = {"iphone": "", "iphone_sig": "", "s22": "", "s22_sig": "", "stand": 0.0}
|
||
_auto = {"an": False, "seit_weg": 0.0, "aufgegeben": ""}
|
||
|
||
|
||
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 _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.
|
||
"""
|
||
# 🔴 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)
|
||
|
||
|
||
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 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)
|
||
|
||
|
||
# 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 done">ℹ️ Keine Ping-Aufzeichnung — Räume markieren und '
|
||
'AP-Pin gehen 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>')
|
||
|
||
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()
|
||
ip_ap, s22_ap = _ap_von(_live["iphone"]), _ap_von(_live["s22"])
|
||
frisch = time.time() - _live["stand"] < 15
|
||
|
||
# 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 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)
|
||
|
||
doppel = "%s · %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
|
||
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",
|
||
)
|
||
)
|
||
|
||
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, 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, 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}}
|
||
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}
|
||
{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){{}} }}
|
||
|
||
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;
|
||
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
|
||
// 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 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 sendauto(ev){{
|
||
try{{
|
||
ev.preventDefault(); unlock(); okton();
|
||
fetch('/auto', {{method:'POST'}}).catch(function(){{}});
|
||
setTimeout(refresh, 400); setTimeout(refresh, 3000);
|
||
return false;
|
||
}}catch(e){{ return true; }}
|
||
}}
|
||
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("/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("/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")
|
||
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()
|
||
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}))
|
||
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.Thread(target=beobachter, daemon=True).start()
|
||
# 🔴 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()
|