Gegen-AP-Automatik: das S22 misst, was das iPhone gerade NICHT misst

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:37:53 +02:00
parent 5231f7b69b
commit 6f1785c881

View File

@@ -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: <b>%s</b> %s dBm" % (mac_name, ap, sig)) if ap else ("%s: <b>nirgends</b>" % mac_name)
doppel = "%s &nbsp;·&nbsp; %s" % (z(ip_ap, _live["iphone_sig"], "iPhone"),
z(s22_ap, _live["s22_sig"], "S22"))
if _pin["busy"]:
pinstatus = "⏳ %s" % _pin["msg"]
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, auf verschiedenen APs<br>%s" % doppel
elif wartet:
pinstatus = "⏳ S22 sucht <b>%s</b> …<br>%s" % (ziel or "?", doppel)
elif ziel in ("ap1", "ap2"):
pinstatus = "S22 haengt fest auf <b>%s</b>" % ziel
pinstatus = "S22 gepinnt auf <b>%s</b><br>%s" % (ziel, doppel)
elif ziel == "off":
pinstatus = "kein Pin — S22 roamt frei"
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"></span>'
'<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\')">'
@@ -333,17 +457,27 @@ def render(st):
'<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 "")
" 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 = ("<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\')">'
@@ -352,18 +486,30 @@ def render(st):
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 ""
# 🚶 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,
last=last_marker(), buttons=btns, notaus=notaus,
)
@@ -409,8 +555,7 @@ form{{margin:0}}
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>
{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.
@@ -453,10 +598,18 @@ 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){{}} }}
var letzterAlarm = 0, letzterBusy = 0, alarmTimer = null;
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
@@ -468,6 +621,14 @@ function signale(){{
letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0;
}}
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();
@@ -548,6 +709,18 @@ class Handler(BaseHTTPRequestHandler):
fh.write("%s %s\n" % (time.strftime("%H:%M:%S"), self.path.split("msg=", 1)[-1]))
self._send(200, "{}")
return
if self.path.startswith("/auto"):
_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"):
@@ -599,6 +772,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
# mit dem Manager und braucht ~8 s. Mit dem alten HTTPServer (single-threaded) haette
# das die ganze Konsole blockiert -- inklusive Not-Aus.