diff --git a/.claude/launch.json b/.claude/launch.json index a002503..0dd8c3e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,8 +4,18 @@ { "name": "shelly-mcp", "runtimeExecutable": "python3", - "runtimeArgs": ["mcp/shelly/server.py"], + "runtimeArgs": [ + "mcp/shelly/server.py" + ], "port": 3030 + }, + { + "name": "tp6-marker", + "runtimeExecutable": "python3", + "runtimeArgs": [ + "docs/tp6-marker/server.py" + ], + "port": 3031 } ] } diff --git a/docs/tp6-auswertung.py b/docs/tp6-auswertung.py new file mode 100644 index 0000000..84551dc --- /dev/null +++ b/docs/tp6-auswertung.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +TP6 — Auswertung: Sampler-CSV + Marker-CSV -> Zahlen je Phase. + +Ohne das ist die Sampler-CSV nur ein Haufen Zeilen. Schneidet die CSV an den +PHASE_*-Marken auf und rechnet je Phase min/avg/max fuer Core-CPU, WAN-Rate und — +sofern ein Client mitgesampelt wurde — Signal und Raten des WLAN-Clients. + +Zusaetzlich werden die Ereignisse gemeldet, die im Mittelwert untergehen: +Roaming (Interface-Wechsel), Re-Assoc (Uptime-Ruecksprung), Verschwinden (absent) +und die Nutzer-Marker (ICH/GELI schlecht, Kamera, Not-Aus). + +Aufruf: + python3 docs/tp6-auswertung.py --sampler [--markers ] +""" +import argparse +import csv +import sys + + +def num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + +def stats(vals): + vals = [v for v in vals if v is not None] + if not vals: + return None + return min(vals), sum(vals) / len(vals), max(vals) + + +def fmt_bps(v): + if v is None: + return "—" + return "%.1f Mbit" % (v / 1e6) if v >= 1e6 else "%.0f kbit" % (v / 1e3) + + +def fmt_rate(v): + return "—" if v is None else "%.0f Mbps" % (v / 1e6) + + +def hms(sec): + return "%02d:%02d" % (sec // 60, sec % 60) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--sampler", required=True) + ap.add_argument("--markers") + args = ap.parse_args() + + rows = list(csv.DictReader(open(args.sampler))) + rows = [r for r in rows if (r.get("ts_epoch") or "").isdigit()] + if not rows: + sys.exit("Sampler-CSV enthaelt keine Datenzeilen.") + + marks = [] + if args.markers: + for m in csv.DictReader(open(args.markers)): + if (m.get("ts_epoch") or "").isdigit(): + marks.append((int(m["ts_epoch"]), m["ts_local"], m["marker"])) + marks.sort() + + # Phasenfenster aus den PHASE_*-Marken; ohne Marker die gesamte Aufzeichnung. + phases = [(ts, name[6:]) for ts, _, name in marks if name.startswith("PHASE_")] + t0, tEnd = int(rows[0]["ts_epoch"]), int(rows[-1]["ts_epoch"]) + windows = [] + if phases: + for i, (ts, name) in enumerate(phases): + end = phases[i + 1][0] if i + 1 < len(phases) else tEnd + 1 + windows.append((name, ts, end)) + else: + windows.append(("gesamte Aufzeichnung", t0, tEnd + 1)) + + has_sta = any((r.get("sta_signal") or "").strip() for r in rows) + print("Sampler: %d Zeilen, %s bis %s\n" % (len(rows), rows[0]["ts_local"], rows[-1]["ts_local"])) + + for name, start, end in windows: + win = [r for r in rows if start <= int(r["ts_epoch"]) < end] + if not win: + continue + dur = int(win[-1]["ts_epoch"]) - int(win[0]["ts_epoch"]) + print("── %s (%s, %d Samples, %s)" % (name, win[0]["ts_local"], len(win), hms(dur))) + + cpu = stats([num(r["cpu"]) for r in win]) + rx = stats([num(r["wan_rx_bps"]) for r in win]) + tx = stats([num(r["wan_tx_bps"]) for r in win]) + if cpu: + print(" Core-CPU min %.0f%% avg %.1f%% max %.0f%%" % cpu) + if rx: + print(" WAN rx avg %s max %s" % (fmt_bps(rx[1]), fmt_bps(rx[2]))) + if tx: + print(" WAN tx avg %s max %s" % (fmt_bps(tx[1]), fmt_bps(tx[2]))) + + if has_sta: + absent = sum(1 for r in win if (r.get("sta_iface") or "") == "absent") + sig = stats([num(r["sta_signal"]) for r in win]) + trate = stats([num(r["sta_tx_rate"]) for r in win]) + rrate = stats([num(r["sta_rx_rate"]) for r in win]) + stx = stats([num(r["sta_tx_bps"]) for r in win]) + srx = stats([num(r["sta_rx_bps"]) for r in win]) + ifaces = [] + for r in win: + i = r.get("sta_iface") or "" + if i and (not ifaces or ifaces[-1] != i): + ifaces.append(i) + if sig: + print(" Client-Sig min %.0f avg %.1f max %.0f dBm" % sig) + if trate and rrate: + print(" Client-Rate tx min %s / avg %s rx min %s / avg %s" + % (fmt_rate(trate[0]), fmt_rate(trate[1]), fmt_rate(rrate[0]), fmt_rate(rrate[1]))) + if stx and srx: + print(" Client-Last tx avg %s / max %s rx avg %s / max %s" + % (fmt_bps(stx[1]), fmt_bps(stx[2]), fmt_bps(srx[1]), fmt_bps(srx[2]))) + print(" AP-Pfad %s%s" % (" -> ".join(ifaces) or "—", + " ⚠️ %d Samples ABSENT" % absent if absent else "")) + + ereignisse = [(t, l, n) for t, l, n in marks if start <= t < end and not n.startswith("PHASE_")] + for _, lt, n in ereignisse: + print(" ⚑ %s %s" % (lt, n)) + print() + + # Re-Assoc erkennen: Uptime springt zurueck. + if has_sta: + reassoc = [] + prev = None + for r in rows: + up = (r.get("sta_uptime") or "").strip() + if not up or up == "absent": + prev = None + continue + parts = [int(p) for p in up.split(":")] if up.count(":") == 2 else None + secs = parts[0] * 3600 + parts[1] * 60 + parts[2] if parts else None + if secs is not None and prev is not None and secs < prev: + reassoc.append((r["ts_local"], r.get("sta_iface", ""))) + prev = secs + if reassoc: + print("⚠️ Re-Assoc / Neuverbindung des Clients:") + for lt, i in reassoc: + print(" %s auf %s" % (lt, i)) + else: + print("Keine Re-Assoc des Clients im gesamten Fenster.") + + +if __name__ == "__main__": + main() diff --git a/docs/tp6-bufferbloat-messung.sh b/docs/tp6-bufferbloat-messung.sh index 5ef111b..ddd9a9c 100755 --- a/docs/tp6-bufferbloat-messung.sh +++ b/docs/tp6-bufferbloat-messung.sh @@ -21,6 +21,13 @@ SETTLE=30 # Sekunden, die die Sättigung stabil läuft, bevor ge UPLOAD_RATE_KB=430 # ~430 KB/s je Flow × 3 = ~10,3 Mbit (wie 2026-06-16) UPLOAD_FILE="$(mktemp -t tp6-upload).bin" +# 🔴 speed.cloudflare.com blockt curl mit Default-User-Agent: /__down antwortet dann mit +# 403 und 1 Byte — die Flows sind sofort tot, die Ping-Reihe sieht sauber aus, und man +# haelt eine NICHT-Messung fuer "kein Bufferbloat". Deshalb UA + Referer mitschicken. +# (Am 2026-08-20 genau so passiert; /__up war davon nicht betroffen.) +CF_UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" +CF_REF="https://speed.cloudflare.com/" + say() { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } warn() { printf '\033[1;33m⚠️ %s\033[0m\n' "$*"; } @@ -50,7 +57,7 @@ mkfile 200M "$UPLOAD_FILE" 2>/dev/null || dd if=/dev/zero of="$UPLOAD_FILE" bs=1 # Linerate-Probe: wie schnell ist der Upload wirklich? (Runbook §2.3/§5.4) say "Linerate-Probe (10 s voller Upload)" -curl -s -o /dev/null --max-time 10 \ +curl -s -o /dev/null --max-time 10 -A "$CF_UA" -e "$CF_REF" \ -w " Upload-Linerate: %{speed_upload} B/s\n" \ --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" || true echo " (Referenz 2026-06-16: ~1,28 MB/s = 10,3 Mbit. Deutlich mehr = Tarif geändert → Rate unten anpassen.)" @@ -61,9 +68,9 @@ ping -c "$PINGS" "$TARGET" # 2. Upload-Sättigung + Ping unter Last say "2. Upload-Sättigung (3 Flows × ${UPLOAD_RATE_KB} KB/s) — ${SETTLE}s stabilisieren" -curl -s -o /dev/null --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP1=$! -curl -s -o /dev/null --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP2=$! -curl -s -o /dev/null --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP3=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP1=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP2=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" --limit-rate "${UPLOAD_RATE_KB}K" --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & UP3=$! sleep "$SETTLE" say "2a. Ping unter Upload-Last" ping -c "$PINGS" "$TARGET" @@ -71,9 +78,9 @@ kill "$UP1" "$UP2" "$UP3" 2>/dev/null; wait 2>/dev/null # 3. Download-Sättigung + Ping unter Last say "3. Download-Sättigung (3 parallele Downloads) — ${SETTLE}s stabilisieren" -curl -s -o /dev/null "https://speed.cloudflare.com/__down?bytes=2000000000" & DL1=$! -curl -s -o /dev/null "https://speed.cloudflare.com/__down?bytes=2000000000" & DL2=$! -curl -s -o /dev/null "https://speed.cloudflare.com/__down?bytes=2000000000" & DL3=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" "https://speed.cloudflare.com/__down?bytes=2000000000" & DL1=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" "https://speed.cloudflare.com/__down?bytes=2000000000" & DL2=$! +curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" "https://speed.cloudflare.com/__down?bytes=2000000000" & DL3=$! sleep "$SETTLE" say "3a. Ping unter Download-Last" ping -c "$PINGS" "$TARGET" diff --git a/docs/tp6-call-run.sh b/docs/tp6-call-run.sh new file mode 100755 index 0000000..2883719 --- /dev/null +++ b/docs/tp6-call-run.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# TP6 — Durchlauf 2: dieselbe Saettigung wie tp6-bufferbloat-messung.sh, aber mit laufendem +# Videocall, mit Phasenmarken auf derselben Zeitachse wie Sampler + Konsole — und mit einer +# Anleitung, die waehrend des Calls auf der Konsole steht. +# +# Messparameter BEWUSST identisch mit Durchlauf 1 (3 Flows x 430 KB/s, 24 Pings, 30 s Settle). +# +# 🔴 Der Treiber schreibt phase.json im 5-Sekunden-Takt. Die Konsole erkennt daran, dass +# ueberhaupt gemessen wird — ohne das geht ein Start-Klick still ins Leere, der Call +# laeuft ohne Last, und "keine Probleme" liest sich wie ein Ergebnis, obwohl es eine +# Nicht-Messung ist. (Am 2026-08-20 genau so passiert: 5,5 Minuten Call, nichts gemessen.) +# +# Misst nur. Aendert nichts am Netz. + +set -uo pipefail + +RUN_DIR="${TP6_RUN_DIR:-/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2}" +mkdir -p "$RUN_DIR" + +MARKERS="$RUN_DIR/markers.csv" +PIDFILE="$RUN_DIR/flows.pid" +STATE="$RUN_DIR/phase.json" +GO_FLAG="$RUN_DIR/go.flag" +ABORT_FLAG="$RUN_DIR/abort.flag" + +TARGET="1.1.1.1" +PINGS=24 +SETTLE=30 +UPLOAD_RATE_KB=430 +C0=90 +C1=60 +STEPS=8 +UPLOAD_FILE="$(mktemp -t tp6-upload).bin" + +CF_UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" +CF_REF="https://speed.cloudflare.com/" + +[[ -f "$MARKERS" ]] || echo "ts_epoch,ts_local,marker" > "$MARKERS" + +MODE="wartet"; PHASE="—"; TITLE=""; INST=""; STEP=0; ENDS=0 + +# Zustand fuer die Konsole. Wird zusaetzlich vom Heartbeat im 5-s-Takt neu geschrieben, +# damit die Konsole einen toten Treiber erkennt. +put_state() { + # 🔴 Atomar schreiben (tmp + mv): die Konsole liest die Datei im Sekundentakt und + # bekaeme sonst halb geschriebenes JSON zu sehen. + python3 - "$STATE" "$MODE" "$PHASE" "$TITLE" "$INST" "$STEP" "$STEPS" "$ENDS" <<'PY' +import json, os, sys +p, mode, phase, title, inst, step, steps, ends = sys.argv[1:9] +tmp = p + ".tmp" +with open(tmp, "w") as fh: + json.dump({"mode": mode, "phase": phase, "title": title, "instruction": inst, + "step": int(step), "steps": int(steps), "ends": int(ends)}, fh) +os.replace(tmp, p) +PY +} + +mark() { printf '%s,%s,%s\n' "$(date +%s)" "$(date +%H:%M:%S)" "$1" >> "$MARKERS"; } + +phase() { # phase + PHASE="$1"; local dur="$2"; TITLE="$3"; INST="$4" + STEP=$((STEP + 1)); ENDS=$(( $(date +%s) + dur )); MODE="messung" + put_state; mark "PHASE_$PHASE" + printf '\n\033[1;36m== %s ==\033[0m %s\n' "$PHASE" "$(date +%H:%M:%S)" +} + +aborted() { [[ -f "$ABORT_FLAG" ]]; } + +cleanup_flows() { + if [[ -f "$PIDFILE" ]]; then + while read -r p; do [[ "$p" =~ ^[0-9]+$ ]] && kill "$p" 2>/dev/null; done < "$PIDFILE" + : > "$PIDFILE" + fi +} + +# 🔴 Der Heartbeat aktualisiert NUR die mtime. Er darf den Zustand nicht schreiben: eine +# Subshell sieht die Variablen vom Fork-Zeitpunkt und wuerde die Anzeige dauerhaft auf +# den Anfangszustand zuruecksetzen (und sich mit dem Hauptprozess ueberschreiben). +( while true; do touch "$STATE" 2>/dev/null; sleep 5; done ) & HEARTBEAT=$! +trap 'kill $HEARTBEAT 2>/dev/null; cleanup_flows; rm -f "$UPLOAD_FILE"' EXIT + +# --- warten auf den Start-Klick ------------------------------------------------------- +rm -f "$GO_FLAG" "$ABORT_FLAG"; : > "$PIDFILE" +MODE="wartet"; PHASE="BEREIT"; STEP=0; ENDS=0 +TITLE="Warte auf den Start-Klick" +INST="Sobald der Call steht: 📞 Call verbunden drücken. Vorher nichts weiter tun — Telefon in die Halterung, keine Downloads." +put_state +echo "Warte auf den Start-Klick …" +while [[ ! -f "$GO_FLAG" ]]; do sleep 0.5; done + +mkfile 200M "$UPLOAD_FILE" 2>/dev/null || dd if=/dev/zero of="$UPLOAD_FILE" bs=1m count=200 2>/dev/null + +# --- C0: Call ohne Last --------------------------------------------------------------- +phase "C0" $((C0 + 25)) "Baseline — Call ohne Last" \ + "Einfach reden. Nichts tun: Telefon nicht bewegen, nichts herunterladen, Kamera anlassen. Das ist der Vergleichsmaßstab für alles Folgende." +ping -c "$PINGS" "$TARGET" > "$RUN_DIR/ping_C0.txt" 2>&1 +sleep "$C0" + +# --- U: Upload-Saettigung ------------------------------------------------------------- +phase "U-settle" "$SETTLE" "Upload-Last baut sich auf" \ + "Ab jetzt ist Stocken erwartbar — und zwar eher bei Geli: sie hört/sieht dich schlechter. Wenn sie etwas meldet: 🟠 drücken. Nicht eingreifen." +for _ in 1 2 3; do + curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" --limit-rate "${UPLOAD_RATE_KB}K" \ + --data-binary @"$UPLOAD_FILE" "https://speed.cloudflare.com/__up" & + echo $! >> "$PIDFILE" +done +sleep "$SETTLE" +if aborted; then + mark "U_ABGEBROCHEN_waehrend_settle" +else + phase "U-ping" $((PINGS + 2)) "Upload-Last + Messung läuft" \ + "Die eigentliche Messung. Weiterreden, Probleme klicken (🟠 Geli / 🔴 du / 🎬 Versatz). Nur wenn der Call abzubrechen droht: ⛔." + ping -c "$PINGS" "$TARGET" > "$RUN_DIR/ping_U.txt" 2>&1 + aborted && mark "U_ABGEBROCHEN_waehrend_ping" +fi +cleanup_flows +phase "U-recovery" 30 "Last ist weg — erholt sich der Call?" \ + "Genau hinhören: wird es sofort wieder gut? Dann 🟢 drücken. Das ist der Beweis, dass die Last die Ursache war." +sleep 30 +rm -f "$ABORT_FLAG" + +# --- D: Download-Saettigung ----------------------------------------------------------- +phase "D-settle" "$SETTLE" "Download-Last baut sich auf" \ + "Jetzt andersherum: eher du hörst/siehst schlechter. Bei Problemen 🔴 drücken." +: > "$PIDFILE" +for _ in 1 2 3; do + curl -s -o /dev/null -A "$CF_UA" -e "$CF_REF" "https://speed.cloudflare.com/__down?bytes=2000000000" & + echo $! >> "$PIDFILE" +done +sleep "$SETTLE" +if aborted; then + mark "D_ABGEBROCHEN_waehrend_settle" +else + phase "D-ping" $((PINGS + 2)) "Download-Last + Messung läuft" \ + "Letzte Lastphase. Weiterreden, Probleme klicken. Not-Aus nur, wenn es kippt." + ping -c "$PINGS" "$TARGET" > "$RUN_DIR/ping_D.txt" 2>&1 + aborted && mark "D_ABGEBROCHEN_waehrend_ping" +fi +cleanup_flows +rm -f "$ABORT_FLAG" + +# --- C1: Ruhiger Abschluss ------------------------------------------------------------ +phase "C1" $((C1 + 25)) "Abschluss — Call ohne Last" \ + "Last ist weg. Noch eine Minute ruhig reden, damit der Vergleich zum Anfang sauber ist. Danach ist die Messung durch." +ping -c "$PINGS" "$TARGET" > "$RUN_DIR/ping_C1.txt" 2>&1 +sleep "$C1" + +# --- Langzeit-Modus: Aufzeichnung laeuft weiter, Messung ist fertig -------------------- +MODE="langzeit"; PHASE="LANGZEIT"; STEP=$STEPS; ENDS=0 +TITLE="Messung abgeschlossen — Aufzeichnung läuft weiter" +INST="Die TP6-Zahlen sind im Kasten. Ab jetzt wird nur noch dein WLAN-Link mitgeschrieben, beliebig lange.

Roaming-Test, wenn du magst: mit laufendem Call 🚶 drücken, langsam in den entferntesten Raum gehen, dort 📍 drücken und ~60 s stehen bleiben, dann zurück (wieder 🚶 / 📍). Alles, was dabei stockt oder asynchron wird, bitte klicken — dann sehe ich, ob genau dann der AP gewechselt hat.

Wenn Schluss ist: 📴." +put_state; mark "PHASE_LANGZEIT" +echo +echo "Messung fertig. Langzeit-Aufzeichnung laeuft — Treiber laeuft weiter (Ctrl-C beendet ihn)." +while true; do sleep 5; done diff --git a/docs/tp6-marker/server.py b/docs/tp6-marker/server.py new file mode 100644 index 0000000..70ea0b6 --- /dev/null +++ b/docs/tp6-marker/server.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +TP6 — Klick-Konsole fuer den Call-Durchlauf. + +Zwei Aufgaben: + 1. FUEHREN: die Seite sagt jederzeit, was gerade laeuft, wie lange es noch dauert und + was der Mensch am Telefon jetzt tun (und lassen) soll. Am Ende sagt sie unmissver- + staendlich, dass die Messung abgeschlossen ist. + 2. MITSCHREIBEN: jeder Klick landet mit Zeitstempel in derselben Zeitachse wie der + Core-Sampler. Der Not-Aus killt zusaetzlich sofort die Saettigungs-Flows. + +🔴 Zwei Dinge, die hier absichtlich so gebaut sind: + - Die Buttons sind server-seitig gerenderte
-POSTs. Faellt das JavaScript aus + (auf dem iPad am 2026-08-20 passiert: es blieb nur der eine statische Button uebrig), + funktioniert die Konsole trotzdem — die Seite laedt dann nach jedem Klick neu. + - Die Seite hat ein . Ohne JS aktualisiert sich die Anleitung dadurch + trotzdem; laeuft JS, entfernt es das Meta-Tag und pollt stattdessen leise. + +Aendert nichts am Netz. Bindet auf 0.0.0.0 (Bedienung vom iPad). +""" +import json +import os +import signal +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +RUN_DIR = os.environ.get( + "TP6_RUN_DIR", + "/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/" + "0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2", +) +SAMPLER = os.environ.get("TP6_SAMPLER", os.path.join(RUN_DIR, "sampler.csv")) +PORT = int(os.environ.get("TP6_PORT", "3031")) + +MARKERS = os.path.join(RUN_DIR, "markers.csv") +PIDFILE = os.path.join(RUN_DIR, "flows.pid") +STATE = os.path.join(RUN_DIR, "phase.json") +GO_FLAG = os.path.join(RUN_DIR, "go.flag") +ABORT_FLAG = os.path.join(RUN_DIR, "abort.flag") + +os.makedirs(RUN_DIR, exist_ok=True) +if not os.path.exists(MARKERS): + with open(MARKERS, "w") as fh: + fh.write("ts_epoch,ts_local,marker\n") + +# name, label, css-Klasse, in welchen Modi sichtbar +BUTTONS = [ + ("CALL_START", "📞 Call verbunden — Messung starten", "start", ("wartet",)), + ("ME_BAD", "🔴 ICH höre/sehe schlecht", "bad-me", ("messung", "langzeit")), + ("GELI_BAD", "🟠 GELI meldet: hört/sieht mich schlecht", "bad-geli", ("messung", "langzeit")), + ("SYNC_OFF", "🎬 Lippen nicht synchron", "bad-me", ("messung", "langzeit")), + ("OK_AGAIN", "🟢 Wieder gut", "ok", ("messung", "langzeit")), + ("CAM_OFF", "📷 Kamera aus", "cam", ("messung", "langzeit")), + ("CAM_ON", "📷 Kamera wieder an", "cam", ("messung", "langzeit")), + ("WALK", "🚶 Ich gehe jetzt los", "walk", ("langzeit",)), + ("ARRIVED", "📍 Ich stehe still", "walk", ("langzeit",)), + ("CALL_END", "📴 Call beendet", "end", ("messung", "langzeit")), +] + +DEFAULT_STATE = { + "mode": "wartet", + "phase": "—", + "title": "Kein Treiber", + "instruction": "Es läuft kein Messtreiber. Ein Start-Klick würde nichts messen.", + "step": 0, "steps": 0, "ends": 0, +} + + +def read_state(): + try: + with open(STATE) as fh: + st = json.load(fh) + st["driver"] = (time.time() - os.path.getmtime(STATE)) < 20 + return st + except Exception: + st = dict(DEFAULT_STATE) + st["driver"] = False + return st + + +def write_marker(name): + with open(MARKERS, "a") as fh: + fh.write("%d,%s,%s\n" % (time.time(), time.strftime("%H:%M:%S"), name)) + fh.flush() + + +def last_marker(): + try: + rows = [r.strip() for r in open(MARKERS) if r.strip()] + if len(rows) < 2: + return "—" + p = rows[-1].split(",") + return "%s (%s)" % (p[2], p[1]) + except Exception: + return "—" + + +def sampler_tail(): + """Letzte Sampler-Zeile fuer die Live-Anzeige. Fehlt sie, ist das kein Fehler.""" + try: + rows = [r.strip() for r in open(SAMPLER) if r.strip()] + if len(rows) < 2: + return {} + row = dict(zip(rows[0].split(","), rows[-1].split(","))) + return { + "age": int(time.time() - int(row.get("ts_epoch", "0"))), + "cpu": row.get("cpu", ""), "wan_rx": row.get("wan_rx_bps", ""), + "wan_tx": row.get("wan_tx_bps", ""), "iface": row.get("sta_iface", ""), + "signal": row.get("sta_signal", ""), "tx_rate": row.get("sta_tx_rate", ""), + "sta_tx_bps": row.get("sta_tx_bps", ""), "sta_rx_bps": row.get("sta_rx_bps", ""), + } + except Exception: + return {} + + +def kill_flows(): + """Not-Aus: Saettigungs-Flows sofort beenden.""" + killed = 0 + try: + pids = [int(p) for p in open(PIDFILE).read().split() if p.strip().isdigit()] + except Exception: + pids = [] + for pid in pids: + try: + os.kill(pid, signal.SIGKILL) + killed += 1 + except Exception: + pass + open(ABORT_FLAG, "w").close() + return killed + + +AP = {"cap-wifi1": "ap1 / 5 GHz", "cap-wifi2": "ap1 / 2,4 GHz", + "cap-wifi3": "ap2 / 5 GHz", "cap-wifi4": "ap2 / 2,4 GHz"} + + +def fmt_bps(v): + try: + n = int(v) + except (TypeError, ValueError): + return "—" + return "%.1f Mbit" % (n / 1e6) if n >= 1e6 else "%d kbit" % (n // 1000) + + +def render(st): + w = sampler_tail() + rest = max(0, int(st.get("ends", 0) - time.time())) if st.get("ends") else 0 + mode = st.get("mode", "wartet") + + banner = "" + if not st.get("driver"): + banner = ('') + elif mode == "langzeit": + banner = ('') + + prog = "" + if st.get("steps"): + prog = '
Schritt %d von %d
' % (st.get("step", 0), st["steps"]) + + countdown = ('
noch ~%d s
' % (st.get("ends", 0), rest) + if rest else '
') + + wlan = "—" + if w.get("iface"): + wlan = "%s · %s dBm · tx %s / rx %s" % ( + AP.get(w["iface"], w["iface"]), w.get("signal", "?"), + fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps"))) + + btns = "\n".join( + '' + '
' % (n, n, c, l) + for n, l, c, modes in BUTTONS if mode in modes + ) + + return PAGE.format( + banner=banner, title=st.get("title", "—"), phase=st.get("phase", "—"), + instruction=st.get("instruction", ""), prog=prog, countdown=countdown, + wlan=wlan, wan="rx %s / tx %s · CPU %s%%" % (fmt_bps(w.get("wan_rx")), + fmt_bps(w.get("wan_tx")), w.get("cpu", "?")), + last=last_marker(), buttons=btns, + ) + + +PAGE = """ + +TP6 — Call-Konsole +{banner} +
+
{phase}
+

{title}

+
{instruction}
+ {countdown} + {prog} +
+
WLAN {wlan} +WAN {wan} +Marker {last}
+{buttons} +
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, body, ctype="application/json"): + raw = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype + "; charset=utf-8") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(raw) + + def _back(self): + self.send_response(303) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + if self.path.startswith("/status"): + st = read_state() + st["sampler"] = sampler_tail() + st["last"] = last_marker() + self._send(200, json.dumps(st)) + elif self.path in ("/", "/index.html"): + self._send(200, render(read_state()), "text/html") + else: + self._send(404, "{}") + + def do_POST(self): + if self.path.startswith("/jserr"): + with open(os.path.join(RUN_DIR, "jserrors.log"), "a") as fh: + fh.write("%s %s\n" % (time.strftime("%H:%M:%S"), self.path.split("msg=", 1)[-1])) + self._send(200, "{}") + return + if self.path.startswith("/m?"): + name = self.path.split("type=", 1)[1].split("&")[0].upper() + if name == "ABORT": + write_marker("ABORT_killed=%d" % kill_flows()) + else: + if name == "CALL_START": + open(GO_FLAG, "w").close() + write_marker(name) + if "text/html" in (self.headers.get("Accept") or ""): + self._back() + else: + self._send(200, json.dumps({"ok": True})) + else: + self._send(404, "{}") + + def log_message(self, *_a): + pass + + +if __name__ == "__main__": + print("Konsole: http://localhost:%d/ · vom iPad: http://10.40.10.183:%d/" % (PORT, PORT)) + print("Marker -> %s" % MARKERS) + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/docs/tp6-monitor-core.sh b/docs/tp6-monitor-core.sh new file mode 100755 index 0000000..1db9505 --- /dev/null +++ b/docs/tp6-monitor-core.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# TP6 — Core-Sampler (read-only) +# +# Haengt EINE persistente SSH-Sitzung an crs326-core und laesst dort eine Console-Schleife +# laufen, die im festen Takt eine CSV-Zeile ausgibt: Core-CPU, WAN-Rate auf vlan100 und +# — optional — die Registration-Table-Werte EINES WLAN-Clients. +# +# 🔴 Ausschliesslich Lesezugriffe (get / find / monitor-traffic once). Kein Write, kein +# /system script, kein /system logging. Aendert nichts am Netz. +# +# 🔴 Unter CAPsMAN fuehrt der MANAGER die Registration-Table — deshalb wird der CORE +# abgefragt und nicht ap1/ap2, wo die Tabelle leer waere. +# +# Aufruf: +# bash docs/tp6-monitor-core.sh --out /pfad/sampler.csv # nur CPU/WAN +# bash docs/tp6-monitor-core.sh --out /pfad/sampler.csv --mac F2:62:4C:E1:AF:09 +# +# Beenden: Ctrl-C (oder kill auf die PID). + +set -uo pipefail + +MAC="" +INTERVAL=2 +OUT="" +ROUTER_IP="10.40.99.1" + +while [[ $# -gt 0 ]]; do + case "$1" in + --mac) MAC="${2:-}"; shift 2 ;; + --interval) INTERVAL="${2:-2}"; shift 2 ;; + --out) OUT="${2:-}"; shift 2 ;; + --ip) ROUTER_IP="${2:-}"; shift 2 ;; + *) echo "unbekanntes Argument: $1" >&2; exit 2 ;; + esac +done + +[[ -n "$OUT" ]] || { echo "--out fehlt" >&2; exit 2; } + +# Passwort aus dem Credential-Store. Wird NIE ausgegeben. +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; } + +# RouterOS-Schleife. Platzhalter werden unten ersetzt (RouterOS nutzt $ selbst, daher kein +# Shell-Interpolieren im Schleifenkoerper). +read -r -d '' ROS <<'ROS_EOF' +:local m "__MAC__"; +:while (true) do={ + :local cpu [/system resource get cpu-load]; + :local w [/interface monitor-traffic vlan100 once as-value]; + :local line ([/system clock get time] . "," . $cpu . "," . ($w->"rx-bits-per-second") . "," . ($w->"tx-bits-per-second")); + :if ([:len $m] > 0) do={ + :local id [/interface/wifi/registration-table find where mac-address=$m]; + :if ([:len $id] = 0) do={ + :set line ($line . ",absent,,,,,,"); + } else={ + :local r [/interface/wifi/registration-table get [:pick $id 0]]; + :set line ($line . "," . ($r->"interface") . "," . ($r->"signal") . "," . ($r->"tx-rate") . "," . ($r->"rx-rate") . "," . ($r->"tx-bits-per-second") . "," . ($r->"rx-bits-per-second") . "," . ($r->"uptime") . "," . ($r->"last-activity")); + }; + }; + :put $line; + :delay __DELAY__s; +} +ROS_EOF + +ROS="${ROS//__MAC__/$MAC}" +ROS="${ROS//__DELAY__/$INTERVAL}" +# 🔴 Die RouterOS-Console parst eine per SSH uebergebene Mehrzeilen-Eingabe ZEILENWEISE +# ("expected closing brace"). Das Skript muss deshalb als EINE Zeile ankommen — +# und jeder do={}-Block braucht dann ein abschliessendes ";", sonst kommt +# "expected end of command". +ROS="$(printf '%s' "$ROS" | tr '\n' ' ')" + +echo "ts_epoch,ts_local,ros_time,cpu,wan_rx_bps,wan_tx_bps,sta_iface,sta_signal,sta_tx_rate,sta_rx_rate,sta_tx_bps,sta_rx_bps,sta_uptime,sta_last_activity" > "$OUT" + +echo "Sampler laeuft -> $OUT (Takt ${INTERVAL}s${MAC:+, Client $MAC})" + +# Lokaler Zeitstempel beim Eintreffen der Zeile. Bewusst kein awk: das BSD-awk auf macOS +# kennt systime()/strftime() nicht (das ist gawk). +sshpass -p "$PW" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 \ + -o ServerAliveInterval=15 "admin@${ROUTER_IP}" "$ROS" 2>"${OUT%.csv}.stderr.log" \ + | while IFS= read -r line; do + printf '%s,%s,%s\n' "$(date +%s)" "$(date +%H:%M:%S)" "$line" >> "$OUT" + done