From 6f1785c881812f0fbb927077c3d91d4e168ee764 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Sat, 22 Aug 2026 20:37:53 +0200 Subject: [PATCH] Gegen-AP-Automatik: das S22 misst, was das iPhone gerade NICHT misst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Christophs Einwand, und er hat recht: wer in ap2-Zone losgeht und das S22 auf ap2 pinnt, misst dort dasselbe, was das frei roamende iPhone ohnehin liefert. Der Erkenntnisgewinn liegt beim ANDEREN AP. Die Konsole liest deshalb beide Telefone mit und zwingt das S22 automatisch auf den Gegen-AP des iPhones. Pro Standort gibt es damit zwei Messpunkte statt einem, ohne den Weg zweimal zu gehen. Ein kurzer Doppelton meldet "beide angemeldet, verschiedene APs" -- das ist das Signal zum Weitergehen. Drei Zustaende statt zwei, weil zwei hier luegen wuerden: bereit beide haengen -> Piep, weiter sucht S22 versucht den Gegen-AP -> stehenbleiben unerreichbar nach 45 s: der Gegen-AP traegt hier NICHT. Das ist das Messergebnis, kein Defekt -- Marker UNERREICHBAR=apX, kein Dauer-Alarm, weitergehen. Ausserdem: - Raumkacheln sind zurueck. Sie hingen am TP6-Treiber-Zustand und waren ohne laufenden Call-Treiber unsichtbar -- ausgerechnet beim Kartieren. - Die Call-Buttons (Start / Not-Aus fuer die Saettigungs-Flows) erscheinen nur noch MIT Treiber. Ohne einen haette der Start-Klick nichts gemessen; der Not-Aus haette nichts zu stoppen gehabt. 🚢/πŸ“ brauchen keinen Treiber und bleiben immer da. Zwei Fehler beim Bauen, beide mit Merkwert: 1. `(:while (true) do={...})` -- die Klammern beenden die RouterOS-Sitzung wortlos. Mit stderr auf DEVNULL sah man nur, dass alle 3 s ein neuer ssh startet, und sonst nichts. stderr geht jetzt in beobachter.log. 2. `for zeile in proc.stdout` liest blockweise vor: bei einer Zeile alle 2 s waeren die ersten Daten nach etwa einer Stunde gekommen. iter(readline, ""). Und eine Sicherheitskorrektur: `sshpass -p` schreibt das Router-Passwort in die Kommandozeile -- sichtbar in ps und in jedem argv-Trace. Beim Debuggen ist es am 2026-08-22 genau so im Klartext gelandet. Der Beobachter nutzt jetzt `sshpass -e` (Passwort nur in der Umgebung des Kindprozesses). tp6-monitor-core.sh und tp25-pin-s22.sh haben dieselbe Schwaeche und sind noch NICHT umgestellt. Co-Authored-By: Claude Opus 5 --- docs/tp6-marker/server.py | 196 +++++++++++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 11 deletions(-) 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 = ( - '
' + '
' '
AP-Pin S22
' '
%s
' '
' @@ -333,17 +457,27 @@ def render(st): '' '
' '
' + '
' + '
' '
' '
' '
' % (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 "") + " 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 mode in ("langzeit", "messung"): + if True: aktuell = ("%s Β· seit %d:%02d" % (raum, seit // 60, seit % 60)) if raum else "β€” noch kein Raum gewΓ€hlt β€”" kacheln = "".join( '
' @@ -352,18 +486,30 @@ def render(st): raumzeile = ('
Wo bist du gerade?
' '
%s
%s
' % (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( '' '
' % (n, n, c, l) for n, l, c, modes in BUTTONS if mode in modes - ) + ) if st.get("driver") else "" + notaus = ('
' + '
' + ) if st.get("driver") else "" + + # 🚢 Gehen/Stehen braucht keinen Treiber -- das sind reine Zeitmarken fuer die Auswertung. + btns = ('
' + '
' + '
' + '
') + 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, + last=last_marker(), buttons=btns, notaus=notaus, ) @@ -409,8 +555,7 @@ form{{margin:0}} WAN {wan} Marker {last}
{buttons} -
-
+{notaus}