diff --git a/docs/tp6-marker/server.py b/docs/tp6-marker/server.py
index 4816e88..ba30171 100644
--- a/docs/tp6-marker/server.py
+++ b/docs/tp6-marker/server.py
@@ -214,6 +214,101 @@ 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")
+
+# 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())
+ 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 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}
@@ -316,16 +411,45 @@ def render(st):
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.
+ bereit = bool(frisch and ip_ap and 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: %s %s dBm" % (mac_name, ap, sig)) if ap else ("%s: nirgends" % mac_name)
+
+ doppel = "%s Β· %s" % (z(ip_ap, _live["iphone_sig"], "iPhone"),
+ z(s22_ap, _live["s22_sig"], "S22"))
+
if _pin["busy"]:
- pinstatus = "β³ %s" % _pin["msg"]
+ pinstatus = "β³ %s
%s" % (_pin["msg"], doppel)
+ elif _auto["aufgegeben"]:
+ pinstatus = ("π« S22 bekommt %s hier nicht β das ist das Messergebnis, "
+ "Raum markieren und weitergehen.
%s" % (_auto["aufgegeben"], doppel))
+ elif bereit:
+ pinstatus = "β
beide angemeldet, auf verschiedenen APs
%s" % doppel
+ elif wartet:
+ pinstatus = "β³ S22 sucht %s β¦
%s" % (ziel or "?", doppel)
elif ziel in ("ap1", "ap2"):
- pinstatus = "S22 haengt fest auf %s" % ziel
+ pinstatus = "S22 gepinnt auf %s
%s" % (ziel, doppel)
elif ziel == "off":
- pinstatus = "kein Pin β S22 roamt frei"
+ pinstatus = "kein Pin β S22 roamt frei
%s" % doppel
else:
pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)"
pinkarte = (
- '