272 lines
13 KiB
Python
272 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TP25 — Gehweg-Auswertung: Signal und Erreichbarkeit pro RAUM.
|
||
|
||
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.
|
||
|
||
python3 docs/tp25-gehweg-auswertung.py --dir <run-dir>
|
||
"""
|
||
import argparse
|
||
import csv
|
||
import os
|
||
import re
|
||
import sys
|
||
import urllib.parse
|
||
|
||
|
||
def sec(t):
|
||
h, m, s = map(int, t.split(":"))
|
||
return h * 3600 + m * 60 + s
|
||
|
||
|
||
# 🔴🔴 Hier stand bis 2026-08-22 eine feste Tabelle -- mit VERTAUSCHTEN APs: cap-wifi1/2
|
||
# sind ap2, nicht ap1 (live belegt ueber cap=, configuration= und Kanal). Die Baender
|
||
# stimmten, die Identitaet nicht. Der Manager vergibt die cap-wifiN-Nummern DYNAMISCH in
|
||
# der Reihenfolge, in der sich die CAPs anmelden -- eine Konstante dazu ist nur bis zum
|
||
# naechsten AP-Reboot richtig und faellt danach still falsch aus.
|
||
#
|
||
# ⚠️ OFFEN: ob die Messungen vom 2026-08-21 (Raumkarte, Sideboard-Test) mit vertauschten
|
||
# AP-Namen ausgewertet wurden, ist NICHT geprueft. Die Rohdaten sind unberuehrt -- es geht
|
||
# allein um die Beschriftung. Betroffen waere u.a. Fund F ("Schlafzimmer -61 ap1 -> -83
|
||
# ap2") und damit die Sideboard-Entscheidung. Vor dem Zitieren alter Zahlen klaeren.
|
||
def ap_karte(run_dir):
|
||
"""cap-wifiN -> apX/Band, aus der Karte DIESES Messlaufs (ap-map.txt).
|
||
|
||
🔴 Ausdruecklich NICHT live nachschlagen. Die Nummern gelten nur fuer den Zeitpunkt der
|
||
Messung; ein spaeter erzeugtes Mapping auf alte Daten anzuwenden ergibt eine
|
||
selbstbewusst falsche Antwort. Fehlt die Datei (alle Laeufe vor 2026-08-22), bleiben
|
||
die rohen cap-wifiN stehen -- unbeschriftet ist ehrlicher als falsch beschriftet.
|
||
"""
|
||
pfad = os.path.join(run_dir, "ap-map.txt")
|
||
if os.path.exists(pfad):
|
||
with open(pfad) as fh:
|
||
return dict(z.strip().split("=", 1) for z in fh if "=" in z)
|
||
print("⚠️ Kein ap-map.txt in diesem Lauf -- AP-Namen bleiben roh (cap-wifiN).\n"
|
||
" Fuer Laeufe vor 2026-08-22 ist die Zuordnung NICHT rekonstruierbar: sie haengt\n"
|
||
" daran, in welcher Reihenfolge sich die CAPs damals angemeldet haben.\n"
|
||
" Anhaltspunkt aus der Physik: der AP im Raum sollte dort das staerkste Signal\n"
|
||
" liefern (ap2 steht in Technik OG, ap1 haengt an core ether15).",
|
||
file=sys.stderr)
|
||
return {}
|
||
|
||
|
||
AP = {}
|
||
|
||
|
||
def ros_ms(v):
|
||
"""RouterOS-Dauer in Millisekunden. Schreibweisen: "12ms", "3ms441us", "529us", "1s200ms"."""
|
||
total = 0.0
|
||
for zahl, einheit in re.findall(r"(\d+)(us|ms|s)", v):
|
||
total += int(zahl) * {"us": 0.001, "ms": 1.0, "s": 1000.0}[einheit]
|
||
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, pin_zeiten=()):
|
||
"""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, luecke_ab = 0.0, 0, None
|
||
for i in range(1, len(p)):
|
||
miss = p[i][1] - p[i - 1][1] - 1
|
||
if miss > 0:
|
||
verloren += miss
|
||
if miss * 0.2 > luecke:
|
||
luecke, luecke_ab = miss * 0.2, p[i - 1][0]
|
||
# 🔴 Die groesste Luecke der Sonde ist oft KEIN Funkbefund, sondern unser eigener
|
||
# Eingriff: beim Umschalten des Gegen-AP wird das Geraet per Deauth geworfen und
|
||
# braucht ein paar Sekunden zurueck. Am 2026-08-22 stand deshalb auf der Treppe
|
||
# "4,6 s ⚠️ eher Ruhezustand" -- das Geraet war von 23:35:12 bis 23:35:16 schlicht
|
||
# abgemeldet, weil um 23:35:10 der Pin von ap1 auf ap2 wechselte. Ein Messartefakt
|
||
# als "Geraet war erreichbar, wollte nur nicht" zu beschriften ist doppelt falsch.
|
||
pin_luecke = luecke_ab is not None and any(
|
||
luecke_ab - 3 <= t <= luecke_ab + luecke + 3 for t in pin_zeiten)
|
||
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,
|
||
"pin_luecke": pin_luecke,
|
||
"leer": not p and not f,
|
||
}
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--dir", required=True)
|
||
a = ap.parse_args()
|
||
|
||
global AP
|
||
AP = ap_karte(a.dir)
|
||
|
||
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"))):
|
||
# 🔴 Marker kommen URL-kodiert aus dem Formular ("RAUM%3DDIELE%20UG") — dekodieren,
|
||
# sonst heisst der Raum "%3DDIELE%20UG" und die Tabelle ist unlesbar.
|
||
mk = urllib.parse.unquote(m["marker"])
|
||
if mk.upper().startswith("RAUM="):
|
||
rooms.append((sec(m["ts_local"]), mk[5:].title()))
|
||
pin_zeiten = [sec(m["ts_local"])
|
||
for m in csv.DictReader(open(os.path.join(a.dir, "markers.csv")))
|
||
if urllib.parse.unquote(m["marker"]).upper().startswith("PIN=")]
|
||
if not rooms:
|
||
raise SystemExit("Keine RAUM=-Marken in markers.csv — nichts zuzuordnen.")
|
||
|
||
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)]
|
||
|
||
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,
|
||
pin_zeiten if "Sonde" in rolle else ()))
|
||
for rolle, ping, funk in gs]
|
||
if all(w["leer"] for _, w in werte):
|
||
continue
|
||
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["pin_luecke"]:
|
||
lz = "%.1f s ⚠️ Pin-Wechsel" % luecke
|
||
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("⚠️ Pin-Wechsel = die Lücke fällt mit dem Umschalten des Gegen-AP zusammen. Das ist")
|
||
print(" unser eigener Eingriff (Deauth + Neuanmeldung), kein Funkbefund. Nur die Sonde.")
|
||
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__":
|
||
main()
|