Initial import from bf-network-r6.opencode — opencode migration

This commit is contained in:
2026-08-26 12:44:21 +02:00
commit 5ee0716dda
297 changed files with 72324 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
#!/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()