TP6 Durchlauf 2 messbar machen: Sampler, gefuehrte Konsole, Treiber
Durchlauf 1 ist gemessen (Ergebnis folgt separat im Runbook). Fuer den Durchlauf
mit laufendem Videocall brauchte es Werkzeug, das es noch nicht gab:
- tp6-monitor-core.sh haengt EINE persistente SSH-Sitzung an den Core und laesst
dort eine Console-Schleife laufen, die im 2-s-Takt CPU, WAN-Rate und die
Registration-Table-Werte eines WLAN-Clients als CSV ausgibt. Nur Lesezugriffe.
Unter CAPsMAN fuehrt der Manager die Tabelle, deshalb wird der Core abgefragt
und nicht ap1/ap2. Zwei RouterOS-Fallen sind im Code kommentiert: die Console
parst eine per SSH uebergebene Mehrzeilen-Eingabe zeilenweise, und in der
Einzeiler-Form braucht jeder do={}-Block ein abschliessendes Semikolon.
- tp6-marker/server.py ist die Konsole fuer die Person am Telefon: Phasenkarte
mit Klartext-Anweisung, Countdown, Fortschritt, Marker-Buttons und Not-Aus,
bedienbar vom iPad. Die Buttons sind server-seitig gerenderte Formulare und
die Anleitung aktualisiert sich notfalls per meta-refresh -- auf dem iPad ist
das JavaScript ausgefallen, uebrig blieb nur der eine statische Button.
- tp6-call-run.sh fuehrt Durchlauf 2 mit denselben Messparametern wie Durchlauf 1
(3 Flows x 430 KB/s, 24 Pings, 30 s Settle) und ergaenzt Ruhephasen davor und
danach, Phasenmarken, eine PID-Datei fuer den Not-Aus und einen Heartbeat.
Der Heartbeat ist kein Detail: ohne ihn geht der Start-Klick still ins Leere,
der Call laeuft ohne Last, und "keine Probleme" liest sich wie ein Ergebnis,
obwohl es eine Nicht-Messung ist -- genau so passiert, 5,5 Minuten lang.
- tp6-auswertung.py schneidet die Sampler-CSV an den Phasenmarken auf und meldet
neben min/avg/max auch die Ereignisse, die im Mittelwert untergehen: Roaming,
Re-Assoc und Verschwinden des Clients.
- tp6-bufferbloat-messung.sh schickt jetzt User-Agent und Referer mit. Ohne die
beantwortet speed.cloudflare.com /__down mit 403 und einem Byte: die Flows
sind sofort tot, die Ping-Reihe sieht sauber aus, und die Download-Seite gilt
faelschlich als bufferbloat-frei. Aufgefallen ist es nur, weil der Sampler am
Core 164 kbit statt 50 Mbit zeigte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
149
docs/tp6-auswertung.py
Normal file
149
docs/tp6-auswertung.py
Normal file
@@ -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 <sampler.csv> [--markers <markers.csv>]
|
||||
"""
|
||||
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()
|
||||
@@ -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"
|
||||
|
||||
156
docs/tp6-call-run.sh
Executable file
156
docs/tp6-call-run.sh
Executable file
@@ -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 <name> <dauer_s> <titel> <anweisung>
|
||||
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: <b>📞 Call verbunden</b> 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. <b>Nichts</b> 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 <b>erwartbar</b> — und zwar eher bei <b>Geli</b>: 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: <b>⛔</b>."
|
||||
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 <b>sofort</b> 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 <b>du</b> 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.<br><br><b>Roaming-Test, wenn du magst:</b> mit laufendem Call <b>🚶</b> drücken, langsam in den entferntesten Raum gehen, dort <b>📍</b> 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.<br><br>Wenn Schluss ist: <b>📴</b>."
|
||||
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
|
||||
321
docs/tp6-marker/server.py
Normal file
321
docs/tp6-marker/server.py
Normal file
@@ -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 <form>-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 <meta refresh>. 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 = ('<div class="banner warn">⚠️ KEIN TREIBER — ein Start-Klick misst NICHTS. '
|
||||
'Erst den Treiber starten lassen.</div>')
|
||||
elif mode == "langzeit":
|
||||
banner = ('<div class="banner done">✅ MESSUNG ABGESCHLOSSEN — die Zahlen liegen vor. '
|
||||
'Ab jetzt läuft nur noch die Langzeit-Aufzeichnung.</div>')
|
||||
|
||||
prog = ""
|
||||
if st.get("steps"):
|
||||
prog = '<div class="steps">Schritt %d von %d</div>' % (st.get("step", 0), st["steps"])
|
||||
|
||||
countdown = ('<div class="cd" id="cd" data-ends="%d">noch ~%d s</div>' % (st.get("ends", 0), rest)
|
||||
if rest else '<div class="cd" id="cd" data-ends="0"></div>')
|
||||
|
||||
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(
|
||||
'<form method="post" action="/m?type=%s" onsubmit="return send(event,\'%s\')">'
|
||||
'<button class="%s" type="submit">%s</button></form>' % (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 = """<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="refresh" content="8" id="metaref">
|
||||
<title>TP6 — Call-Konsole</title><style>
|
||||
:root{{color-scheme:dark}}
|
||||
body{{margin:0;padding:14px;background:#14161a;color:#e8eaed;
|
||||
font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}}
|
||||
.banner{{padding:12px 14px;border-radius:10px;font-weight:700;margin-bottom:12px}}
|
||||
.warn{{background:#7f1d1d;color:#fff}} .done{{background:#14532d;color:#d1fae5}}
|
||||
.card{{background:#1c1f26;border:1px solid #2a2f3a;border-radius:12px;padding:14px;margin-bottom:12px}}
|
||||
.phase{{font-size:13px;color:#7dd3fc;text-transform:uppercase;letter-spacing:.06em}}
|
||||
h2{{margin:4px 0 8px;font-size:21px}}
|
||||
.inst{{font-size:17px;color:#e8eaed}}
|
||||
.cd{{margin-top:10px;font:600 15px ui-monospace,Menlo,monospace;color:#fbbf24}}
|
||||
.steps{{margin-top:6px;font-size:13px;color:#9aa4b2}}
|
||||
.live{{background:#1c1f26;border:1px solid #2a2f3a;border-radius:10px;padding:10px 12px;
|
||||
margin-bottom:14px;font:13px/1.7 ui-monospace,Menlo,monospace;white-space:pre-wrap;color:#9aa4b2}}
|
||||
button{{width:100%;margin:0 0 10px;padding:20px 16px;font-size:19px;font-weight:600;border:none;
|
||||
border-radius:12px;background:#262b34;color:#e8eaed;-webkit-tap-highlight-color:transparent}}
|
||||
button:active{{filter:brightness(1.3)}}
|
||||
.start{{background:#14532d}} .bad-me{{background:#7f1d1d}} .bad-geli{{background:#7c3f12}}
|
||||
.ok{{background:#166534}} .cam{{background:#2b3648}} .walk{{background:#1e3a5f}} .end{{background:#3a2b4a}}
|
||||
.abort{{background:#b91c1c;font-size:23px;padding:30px 16px;box-shadow:0 0 0 3px #7f1d1d inset}}
|
||||
form{{margin:0}}
|
||||
</style>
|
||||
{banner}
|
||||
<div class="card">
|
||||
<div class="phase">{phase}</div>
|
||||
<h2>{title}</h2>
|
||||
<div class="inst">{instruction}</div>
|
||||
{countdown}
|
||||
{prog}
|
||||
</div>
|
||||
<div class="live">WLAN {wlan}
|
||||
WAN {wan}
|
||||
Marker {last}</div>
|
||||
{buttons}
|
||||
<form method="post" action="/m?type=ABORT" onsubmit="return send(event,'ABORT')">
|
||||
<button class="abort" type="submit">⛔ LAST SOFORT STOPPEN</button></form>
|
||||
<script>
|
||||
// JS ist Komfort, keine Voraussetzung: die Buttons sind Formulare, die Anleitung kommt
|
||||
// notfalls per <meta refresh>. Laeuft JS, ersetzen wir das durch leises Nachladen.
|
||||
window.onerror = function(m, s, l, c){{
|
||||
try{{ fetch('/jserr?msg=' + encodeURIComponent(m + ' @' + l + ':' + c), {{method:'POST'}}); }}catch(e){{}}
|
||||
}};
|
||||
function send(ev, name){{
|
||||
try{{
|
||||
ev.preventDefault();
|
||||
fetch('/m?type=' + encodeURIComponent(name), {{method:'POST'}}).then(refresh);
|
||||
var b = ev.target.querySelector('button');
|
||||
if(b){{ b.style.filter='brightness(1.6)'; setTimeout(function(){{b.style.filter='';}},400); }}
|
||||
return false;
|
||||
}}catch(e){{ return true; }} // im Zweifel den normalen Form-POST durchlassen
|
||||
}}
|
||||
function refresh(){{
|
||||
fetch('/').then(function(r){{return r.text();}}).then(function(html){{
|
||||
var d = document.createElement('html'); d.innerHTML = html;
|
||||
var n = d.querySelector('.card'), o = document.querySelector('.card');
|
||||
if(n && o) o.replaceWith(n);
|
||||
var nb = d.querySelector('.live'), ob = document.querySelector('.live');
|
||||
if(nb && ob) ob.replaceWith(nb);
|
||||
var nban = d.querySelector('.banner'), oban = document.querySelector('.banner');
|
||||
if(nban && oban) oban.replaceWith(nban); else if(nban && !oban) document.body.prepend(nban);
|
||||
tick();
|
||||
}}).catch(function(){{}});
|
||||
}}
|
||||
function tick(){{
|
||||
var el = document.getElementById('cd'); if(!el) return;
|
||||
var ends = parseInt(el.dataset.ends || '0', 10); if(!ends) {{ el.textContent=''; return; }}
|
||||
var r = Math.max(0, ends - Math.floor(Date.now()/1000));
|
||||
el.textContent = r ? 'noch ~' + r + ' s' : 'gleich weiter …';
|
||||
}}
|
||||
var mr = document.getElementById('metaref'); if(mr) mr.remove(); // JS lebt -> kein Vollreload
|
||||
setInterval(tick, 1000); setInterval(refresh, 3000);
|
||||
// Bildschirm wachhalten: gesperrtes iPad = kein Not-Aus.
|
||||
async function wake(){{ try{{ if('wakeLock' in navigator) await navigator.wakeLock.request('screen'); }}catch(e){{}} }}
|
||||
document.addEventListener('visibilitychange', function(){{ if(!document.hidden) wake(); }});
|
||||
wake();
|
||||
</script>"""
|
||||
|
||||
|
||||
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()
|
||||
84
docs/tp6-monitor-core.sh
Executable file
84
docs/tp6-monitor-core.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user