diff --git a/docs/tp25-gehweg-auswertung.py b/docs/tp25-gehweg-auswertung.py index a0b1f3f..12436f5 100644 --- a/docs/tp25-gehweg-auswertung.py +++ b/docs/tp25-gehweg-auswertung.py @@ -2,10 +2,19 @@ """ TP25 — Gehweg-Auswertung: Signal und Erreichbarkeit pro RAUM. -Verbindet die drei Mitschnitte der Gehweg-Messung zu einer Raumtabelle: - walk-ping.log (5 Pings/s auf das Telefon) -> Verlust und laengste Luecke - sampler.csv (Funkwerte am Manager) -> AP/Band und Signal - markers.csv (RAUM=-Klicks) -> die Raumgrenzen auf der Zeitachse +Verbindet die Mitschnitte der Gehweg-Messung zu einer Raumtabelle: + walk-ping.csv (5 Pings/s auf die SONDE) -> Verlust und laengste Luecke + walk-ping-iphone.csv (5 Pings/s auf die REFERENZ) -> dito, seit 2026-08-22 + beide-telefone.csv (AP + Signal beider Geraete) -> seit 2026-08-22 + sampler.csv (Funkdetail der Sonde) -> Signal, PHY-Rate, Durchsatz + markers.csv (RAUM=-Klicks) -> Raumgrenzen auf der Zeitachse + +🔎 Zwei Geraete, zwei Rollen (Messaufbau seit 2026-08-22): + REFERENZ = iPhone, roamt frei -> was ein normaler Client hier wirklich erlebt. + SONDE = S22, per access-list auf den GEGEN-AP gezwungen -> was die Rueckfallebene + an derselben Stelle noch leisten koennte. + Nebeneinander beantworten sie die Frage, die eine Messung allein nicht kann: + ist es hier gut, und was passiert, wenn der gute AP wegfaellt? Damit wird aus "irgendwo zwischen 15:07 und 15:09 war es schlecht" ein Satz mit Raumnamen. @@ -66,6 +75,99 @@ def ros_ms(v): return total +def lade_pings(pfad): + """(sekunde, seq) je Antwort. Verluste stehen als Luecken in den Sequenznummern.""" + treffer = [] + if not os.path.exists(pfad): + return treffer + for r in csv.DictReader(open(pfad)): + if not (r.get("seq") or "").isdigit(): + continue # RECONNECT-Zeilen + if (r.get("ms") or "").strip(): + treffer.append((sec(r["ts_local"]), int(r["seq"]))) + return treffer + + +def lade_pings_alt(pfad): + """Altes Format: BSD-ping vom Laptop.""" + treffer = [] + for line in open(pfad): + m = re.match(r"(\d\d:\d\d:\d\d) 64 bytes.*icmp_seq=(\d+)", line) + if m: + treffer.append((sec(m.group(1)), int(m.group(2)))) + return treffer + + +def geraete(run_dir): + """Liste von Geraeten: (rolle, ping-Liste, funk-Liste). + + funk-Liste = (sekunde, iface, signal, phy_rate_oder_None). Die PHY-Rate gibt es nur + fuer die Sonde (sampler.csv, 1-s-Takt); fuer die Referenz liefert beide-telefone.csv + nur AP und Signal im 2-s-Takt. Das ist bewusst so und wird unten beim Ruhezustand + beruecksichtigt -- ohne Rate ist der Befund schwaecher und wird auch so benannt. + """ + pfad = lambda n: os.path.join(run_dir, n) + + sonde_funk = [] + if os.path.exists(pfad("sampler.csv")): + for r in csv.DictReader(open(pfad("sampler.csv"))): + if r.get("sta_signal"): + sonde_funk.append((sec(r["ts_local"]), r.get("sta_iface") or "", + int(r["sta_signal"]), + int(r["sta_tx_rate"]) if (r.get("sta_tx_rate") or "").isdigit() else None)) + + ref_funk = [] + if os.path.exists(pfad("beide-telefone.csv")): + for r in csv.DictReader(open(pfad("beide-telefone.csv"))): + if (r.get("iphone_sig") or "").lstrip("-").isdigit(): + ref_funk.append((sec(r["ts_local"]), r.get("iphone_iface") or "", + int(r["iphone_sig"]), None)) + + sonde_ping = lade_pings(pfad("walk-ping.csv")) + if not sonde_ping and os.path.exists(pfad("walk-ping.log")): + sonde_ping = lade_pings_alt(pfad("walk-ping.log")) + ref_ping = lade_pings(pfad("walk-ping-iphone.csv")) + + if not ref_ping and not ref_funk: + # Lauf vor 2026-08-22: nur ein Geraet. Welches, ist aus den Dateien nicht + # ableitbar -- also wird es auch nicht behauptet. + return [("Gerät", sonde_ping, sonde_funk)] + return [("Referenz (frei)", ref_ping, ref_funk), + ("Sonde (Gegen-AP)", sonde_ping, sonde_funk)] + + +def auswerten(ping, funk, start, stop): + """Verlust, laengste Luecke, Signal und APs eines Geraets in einem Zeitfenster.""" + p = [x for x in ping if start <= x[0] < stop] + f = [x for x in funk if start <= x[0] < stop] + luecke, verloren = 0.0, 0 + for i in range(1, len(p)): + miss = p[i][1] - p[i - 1][1] - 1 + if miss > 0: + verloren += miss + luecke = max(luecke, miss * 0.2) + gesamt = (p[-1][1] - p[0][1] + 1) if len(p) > 1 else 0 + sig = [x[2] for x in f] + raten = [x[3] for x in f if x[3]] + # 🔴 Ruhezustand von Funkloch trennen: bleibt das Signal gut UND die PHY-Rate hoch, + # war das Geraet erreichbar und wollte nur nicht antworten (iOS bei gesperrtem + # Display). Ohne Ratendaten ist das nur ein Verdacht -- und wird so beschriftet. + gut = bool(sig) and (sum(sig) / len(sig)) > -68 + schlaefrig = luecke >= 3 and gut and bool(raten) and (sum(raten) / len(raten)) > 100e6 + verdacht = luecke >= 3 and gut and not raten + # 🔴 "Funk da, aber KEINE einzige Ping-Antwort" ist ein eigener Zustand und darf nicht + # als "—" (= keine Daten) erscheinen. Beim Abnahmetest am 2026-08-22 lag das iPhone + # gesperrt auf dem Tisch: angemeldet mit -50 dBm, und trotzdem stumm. Wer das als + # Datenluecke liest, sucht anschliessend ein Funkloch, das es nicht gibt. + stumm = (len(p) <= 1) and bool(sig) + return { + "sig": sig, "luecke": luecke, "verloren": verloren, "gesamt": gesamt, + "ifaces": sorted({AP.get(x[1], x[1]) for x in f if x[1]}), + "schlaefrig": schlaefrig, "verdacht": verdacht, "stumm": stumm, "gut": gut, + "leer": not p and not f, + } + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--dir", required=True) @@ -74,27 +176,9 @@ def main(): global AP AP = ap_karte(a.dir) - pings = [] - csv_path = os.path.join(a.dir, "walk-ping.csv") - if os.path.exists(csv_path): - # Neues Format: Ping vom Core, "ts_local,seq,ms" — leeres ms = Verlust. - for r in csv.DictReader(open(csv_path)): - if not (r.get("seq") or "").isdigit(): - continue # RECONNECT-Zeilen - ms = (r.get("ms") or "").strip() - if ms: - pings.append((sec(r["ts_local"]), int(r["seq"]), ros_ms(ms))) - else: - # Altes Format: BSD-ping vom Laptop. - for line in open(os.path.join(a.dir, "walk-ping.log")): - m = re.match(r"(\d\d:\d\d:\d\d) 64 bytes.*icmp_seq=(\d+).*time=([\d.]+)", line) - if m: - pings.append((sec(m.group(1)), int(m.group(2)), float(m.group(3)))) - if not pings: - raise SystemExit("Keine Ping-Antworten gefunden.") - - rows = [r for r in csv.DictReader(open(os.path.join(a.dir, "sampler.csv"))) - if r.get("sta_signal")] + gs = geraete(a.dir) + if not any(g[1] or g[2] for g in gs): + raise SystemExit("Weder Ping-Antworten noch Funkproben gefunden.") rooms = [] for m in csv.DictReader(open(os.path.join(a.dir, "markers.csv"))): @@ -102,53 +186,66 @@ def main(): # sonst heisst der Raum "%3DDIELE%20UG" und die Tabelle ist unlesbar. mk = urllib.parse.unquote(m["marker"]) if mk.upper().startswith("RAUM="): - rooms.append((int(m["ts_epoch"]), sec(m["ts_local"]), mk[5:].title())) + rooms.append((sec(m["ts_local"]), mk[5:].title())) if not rooms: raise SystemExit("Keine RAUM=-Marken in markers.csv — nichts zuzuordnen.") - ende = max(pings[-1][0], sec(rows[-1]["ts_local"]) if rows else 0) + 1 - fenster = [(r[1], rooms[i + 1][1] if i + 1 < len(rooms) else ende, r[2]) + ende = max([x[0] for g in gs for x in (g[1] + [(0, 0)])] + + [x[0] for g in gs for x in (g[2] + [(0, 0, 0, None)])]) + 1 + fenster = [(r[0], rooms[i + 1][0] if i + 1 < len(rooms) else ende, r[1]) for i, r in enumerate(rooms)] - print("%-16s %6s %-24s %-18s %s" % ("Raum", "Dauer", "Signal min/avg/max", "Verlust", "längste Lücke")) - print("-" * 92) - for start, stop, name in fenster: - p = [x for x in pings if start <= x[0] < stop] - r = [x for x in rows if start <= sec(x["ts_local"]) < stop] - if not p and not r: + mehrere = len(gs) > 1 + if mehrere: + print("REFERENZ = iPhone, roamt frei (was du hier wirklich hast)") + print("SONDE = S22, auf den Gegen-AP gezwungen (was die Rückfallebene noch kann)\n") + print("%-15s %-17s %5s %-22s %-17s %s" + % ("Raum", "Rolle", "Dauer", "Signal min/⌀/max", "Verlust", "längste Lücke")) + print("-" * 100) + + for start_s, stop_s, name in fenster: + werte = [(rolle, auswerten(ping, funk, start_s, stop_s)) for rolle, ping, funk in gs] + if all(w["leer"] for _, w in werte): continue - # Verlust und laengste Luecke aus den Sequenznummern - luecke, verloren = 0.0, 0 - for i in range(1, len(p)): - miss = p[i][1] - p[i - 1][1] - 1 - if miss > 0: - verloren += miss - luecke = max(luecke, miss * 0.2) - gesamt = (p[-1][1] - p[0][1] + 1) if len(p) > 1 else 0 - # 🔴 Ruhezustand von Funkloch trennen. Ein iPhone mit gesperrtem Display - # beantwortet ICMP nicht — das sieht in der Verlustspalte aus wie ein Funkloch. - # Unterscheidbar ist es am Funk: bleibt das Signal gut UND die PHY-Rate hoch, - # war das Geraet erreichbar und wollte nur nicht antworten. - # (Am 2026-08-21 auf der Terrasse: 58 s Ping-Luecke bei -53 dBm und 275 Mbps.) - sig = [int(x["sta_signal"]) for x in r] - raten = [int(x["sta_tx_rate"] or 0) for x in r if x.get("sta_tx_rate")] - # Bewertet wird der DURCHSCHNITT des Abschnitts, nicht sein Minimum: ein - # einzelner schwacher Sample beim Durchgehen darf den Befund nicht kippen. - schlaefrig = (luecke >= 3 and sig and (sum(sig) / len(sig)) > -68 - and raten and (sum(raten) / len(raten)) > 100e6) - ifaces = sorted({AP.get(x["sta_iface"], x["sta_iface"]) for x in r if x["sta_iface"]}) - print("%-16s %5ds %-24s %-18s %s" % ( - name, stop - start, - "%d / %.1f / %d dBm" % (min(sig), sum(sig) / len(sig), max(sig)) if sig else "—", - "%d von %d (%.0f %%)" % (verloren, gesamt, 100 * verloren / gesamt) if gesamt else "—", - (("%.1f s ⚠️ eher Ruhezustand" % luecke) if schlaefrig - else ("%.1f s" % luecke)) if luecke else "keine")) - if ifaces: - print("%-16s auf %s" % ("", ", ".join(ifaces))) - print("\n⚠️ = Luecke bei gutem Signal und hoher PHY-Rate -> das Geraet war erreichbar und hat") - print(" nur nicht geantwortet (iOS-Ruhezustand). Solche Luecken NICHT als Funkloch lesen.") - print("\nLesehilfe: eine längste Lücke über ~2 s ist für einen Videocall eine hörbare Unterbrechung,") - print("über 10 s ist der Call praktisch tot. Signal unter −70 dBm ist die Zone, in der das passiert.") + erste = True + for rolle, w in werte: + if w["leer"]: + continue + sig, luecke = w["sig"], w["luecke"] + if w["stumm"]: + lz = "⚠️ Display gesperrt?" if w["gut"] else "⚠️ keine Antwort" + elif luecke and w["schlaefrig"]: + lz = "%.1f s ⚠️ eher Ruhezustand" % luecke + elif luecke and w["verdacht"]: + lz = "%.1f s ⚠️ evtl. Ruhezustand" % luecke + elif luecke: + lz = "%.1f s" % luecke + else: + lz = "keine" + print("%-15s %-17s %4ds %-22s %-17s %s" % ( + name if erste else "", rolle, stop_s - start_s, + "%d / %.1f / %d dBm" % (min(sig), sum(sig) / len(sig), max(sig)) if sig else "—", + ("%d von %d (%.0f %%)" % (w["verloren"], w["gesamt"], + 100 * w["verloren"] / w["gesamt"]) if w["gesamt"] + else ("keine Antwort" if w["stumm"] else "—")), + lz)) + if w["ifaces"]: + print("%-15s %-17s auf %s" % ("", "", ", ".join(w["ifaces"]))) + erste = False + + print("\n⚠️ eher Ruhezustand = Lücke bei gutem Signal UND hoher PHY-Rate -> das Gerät war") + print(" erreichbar und hat nur nicht geantwortet (iOS mit gesperrtem Display).") + print("⚠️ evtl. Ruhezustand = dasselbe Muster, aber ohne PHY-Rate zum Gegenprüfen") + print(" (die Referenz liefert nur AP und Signal). Verdacht, kein Befund.") + print("⚠️ Display gesperrt? = Funkverbindung stand mit gutem Signal, aber KEINE einzige") + print(" Ping-Antwort. Das ist kein Funkloch und keine Datenlücke — bei iOS beantwortet") + print(" ein gesperrtes Display kein ICMP. Beim nächsten Lauf Display anlassen.") + print("\nLesehilfe: eine längste Lücke über ~2 s ist für einen Videocall eine hörbare") + print("Unterbrechung, über 10 s ist der Call praktisch tot. Signal unter −70 dBm ist die") + print("Zone, in der das passiert.") + if mehrere: + print("\nDie interessante Spalte ist der Unterschied: gute Referenz + schlechte Sonde") + print("= hier hängt alles an EINEM AP. Beide schlecht = echtes Loch ohne Rückfallebene.") if __name__ == "__main__": diff --git a/docs/tp25-pin-s22.sh b/docs/tp25-pin-s22.sh index 6c96f9a..2c350df 100755 --- a/docs/tp25-pin-s22.sh +++ b/docs/tp25-pin-s22.sh @@ -47,8 +47,11 @@ ZIEL="${1:-status}" PW="$(grep -m1 '^CRS326_CORE_PASS=' ~/.secrets/credentials.env | cut -d= -f2- | sed 's/^["'"'"']//; s/["'"'"']$//')" [[ -n "$PW" ]] || { echo "CRS326_CORE_PASS nicht in ~/.secrets/credentials.env gefunden" >&2; exit 1; } +# 🔴 sshpass -e statt -p: mit -p steht das Router-Passwort in der Kommandozeile und damit +# in `ps` und in jedem Prozess-Trace. Am 2026-08-22 ist es genau so im Klartext in einen +# Sitzungsmitschnitt geraten. Die sed-Maskierung der Ausgabe bleibt als zweite Schicht. ros() { - sshpass -p "$PW" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 \ + SSHPASS="$PW" sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 \ "admin@${ROUTER_IP}" "$1" 2>&1 | sed "s/${PW//\//\\/}/***/g" } diff --git a/docs/tp25-runde.sh b/docs/tp25-runde.sh index 70f5956..5dbfafa 100755 --- a/docs/tp25-runde.sh +++ b/docs/tp25-runde.sh @@ -36,6 +36,14 @@ else sleep 2 fi +# 🔴 Gegen-AP-Automatik gleich einschalten. Sie war bisher ein Klick auf der Konsole -- und +# wer ihn vergisst, geht die ganze Runde mit beiden Telefonen auf DEMSELBEN AP und merkt +# es erst bei der Auswertung (im Test am 2026-08-22 genau so passiert). Abschalten geht +# weiter jederzeit ueber den Knopf. +curl -s -X POST --max-time 10 "http://127.0.0.1:3031/auto?an=1" >/dev/null 2>&1 \ + && echo "Gegen-AP-Automatik: EIN" \ + || echo "⚠️ Automatik konnte nicht eingeschaltet werden -- auf der Konsole nachholen!" + echo echo "════════════════════════════════════════════════════════════" echo " Konsole am Telefon oeffnen:" @@ -45,7 +53,7 @@ ifconfig | awk '/^[a-z0-9]+:/{i=substr($1,1,length($1)-1)} /inet /{if ($2 !~ /^127\.|^169\.254\./) printf " http://%s:3031/ (%s)\n", $2, i}' echo echo " 1) Auf dem Telefon einmal irgendwo tippen -> schaltet den Signalton frei" -echo " 2) '🔁 Gegen-AP automatisch' einschalten -> S22 folgt dem iPhone spiegelverkehrt" +echo " 2) Gegen-AP-Automatik laeuft bereits -> S22 folgt dem iPhone spiegelverkehrt" echo " 3) Losgehen. Doppelton = beide angemeldet -> Raum antippen, weiter" echo echo " Lauf: $TP6_RUN_DIR" @@ -55,12 +63,18 @@ echo aufraeumen() { echo echo "Runde beendet. Aufraeumen …" - bash docs/tp25-pin-s22.sh off >/dev/null 2>&1 && echo " AP-Pin geloest." + # 🔴 REIHENFOLGE: erst die Konsole toeten, dann den Pin loesen. Andersherum ist es ein + # Wettlauf, den man verliert -- die Automatik der noch laufenden Konsole sieht den + # geloesten Pin und setzt ihn binnen zwei Sekunden wieder. Am 2026-08-22 meldete der + # Aufraeumer brav "AP-Pin geloest", und danach standen die Regeln trotzdem am Core: + # das Telefon waere nach jeder Runde gepinnt geblieben. [[ -n "$KONSOLE" ]] && kill "$KONSOLE" 2>/dev/null && echo " Konsole beendet." # Muster bewusst ohne -e/-p: es gab beide Varianten, und die enge Fassung liess den # Sampler stehen. pkill -f 'sshpass.*10.40.99.1' 2>/dev/null pkill -f 'tp6-monitor-core.sh' 2>/dev/null + sleep 1 + bash docs/tp25-pin-s22.sh off >/dev/null 2>&1 && echo " AP-Pin geloest." echo echo "Auswertung:" echo " python3 docs/tp25-gehweg-auswertung.py --dir $TP6_RUN_DIR" diff --git a/docs/tp6-marker/server.py b/docs/tp6-marker/server.py index 0b71806..fba3bb2 100644 --- a/docs/tp6-marker/server.py +++ b/docs/tp6-marker/server.py @@ -768,7 +768,13 @@ class Handler(BaseHTTPRequestHandler): self.headers.get("Accept") or "") else self._back() return if self.path.startswith("/auto"): - _auto["an"] = not _auto["an"] + # 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"))