Auswertung zeigt beide Rollen nebeneinander -- und drei Fallen aus dem Test

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:28:41 +02:00
parent 6cd9db61d5
commit ac4bce8cc2
4 changed files with 190 additions and 70 deletions

View File

@@ -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__":

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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"))