From ac4bce8cc254658e022feff0d7c6777c02fc2448 Mon Sep 17 00:00:00 2001 From: Christoph Emsenhuber Date: Sat, 22 Aug 2026 23:28:41 +0200 Subject: [PATCH] Auswertung zeigt beide Rollen nebeneinander -- und drei Fallen aus dem Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/tp25-gehweg-auswertung.py | 229 +++++++++++++++++++++++---------- docs/tp25-pin-s22.sh | 5 +- docs/tp25-runde.sh | 18 ++- docs/tp6-marker/server.py | 8 +- 4 files changed, 190 insertions(+), 70 deletions(-) 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"))