Zweiter Gehweg-Durchgang, diesmal methodisch sauber: Ping vom Core statt vom Laptop, Konsole auf dem iPhone (Wake Lock haelt es wach, sonst misst man Ruhezustand), Raumwechsel per Klick markiert. Das Ergebnis ist eindeutig und macht die Roaming-Geschichte klein. Im ganzen Haus null Paketverlust -- Technik UG und OG, Diele, Kinder-, Schlaf- und Arbeitszimmer, Treppe, alle 0 %, und die Uebergaben ap1<->ap2 auf der Treppe kosten 0,2 s. Auf der Terrasse dagegen 43 % Verlust ueber 162 Sekunden, am Stueck fehlen 20,4 Sekunden, bei -72 dBm. Dort ist ein Call nicht gestoert, sondern weg. Die Topologie erklaert es vollstaendig: ap2 steht in Technik OG, das nur ueber das Arbeitszimmer zugaenglich ist -- zur Terrasse liegen Technik OG, Arbeitszimmer, Diele OG und die Aussenwand dazwischen. Das dreht die Massnahmen um. Ein signal-range-Boden (Fund E) wuerde die Terrasse VERSCHLECHTERN, weil es dort keinen besseren AP gibt, auf den er steuern koennte -- er wuerde nur rauswerfen. Stattdessen: 5 GHz auf 20 MHz (+3 dB, ein Wort im Template), dann ap2 von Technik OG in die Diele OG umhaengen, die an Terrasse, Arbeitszimmer und Treppe grenzt. Kein neuer AP noetig, damit ist auch die User-Randbedingung erfuellt. Konsole nachgezogen: Marker werden URL-dekodiert und behalten ihre Gross- und Kleinschreibung (aus "RAUM=Diele OG" wurde "RAUM%3DDIELE%20OG"), Diele OG ist ergaenzt, und das Raumraster steht jetzt oben -- ohne JavaScript laedt die Seite nach jedem Klick neu, und dann muss der naechste Raum ohne Scrollen erreichbar sein. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
15 KiB
Python
362 lines
15 KiB
Python
#!/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 urllib.parse
|
|
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"))
|
|
|
|
# Raeume fuer die Gehweg-/Dead-Spot-Kartierung. Reihenfolge = Reihenfolge auf der Seite.
|
|
# Aenderbar ohne Codeeingriff: TP25_ROOMS="Kueche,Bad,..." python3 docs/tp6-marker/server.py
|
|
ROOMS = [r.strip() for r in os.environ.get(
|
|
"TP25_ROOMS",
|
|
"Küche,Wohnzimmer,Diele UG,Technik UG,Treppe,Kinderzimmer,Schlafzimmer,Diele OG,Arbeitszimmer,Bad,Klo,Technik OG,Terrasse"
|
|
).split(",") if r.strip()]
|
|
|
|
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 current_room():
|
|
"""Letzte Raum-Marke plus Verweildauer — damit auf der Seite steht, was mitgeschrieben wird."""
|
|
try:
|
|
for row in reversed([r.strip() for r in open(MARKERS) if r.strip()]):
|
|
p = row.split(",")
|
|
if len(p) >= 3 and p[2].startswith("RAUM="):
|
|
return p[2][5:], int(time.time() - int(p[0]))
|
|
except Exception:
|
|
pass
|
|
return None, 0
|
|
|
|
|
|
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")))
|
|
|
|
raum, seit = current_room()
|
|
raumzeile = ""
|
|
if mode in ("langzeit", "messung"):
|
|
aktuell = ("<b>%s</b> · seit %d:%02d" % (raum, seit // 60, seit % 60)) if raum else "— noch kein Raum gewählt —"
|
|
kacheln = "".join(
|
|
'<form method="post" action="/m?type=RAUM%%3D%s" onsubmit="return send(event,\'RAUM=%s\')">'
|
|
'<button class="raum%s" type="submit">%s</button></form>'
|
|
% (r.replace(" ", "%20"), r, " hier" if r == raum else "", r) for r in ROOMS)
|
|
raumzeile = ('<div class="card"><div class="phase">Wo bist du gerade?</div>'
|
|
'<div class="inst">%s</div><div class="raster">%s</div></div>' % (aktuell, kacheln))
|
|
|
|
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,
|
|
raumzeile=raumzeile, 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}}
|
|
.raster{{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}}
|
|
.raster form{{margin:0}}
|
|
.raum{{margin:0;padding:16px 8px;font-size:16px;background:#1e3a5f}}
|
|
.raum.hier{{background:#0ea5e9;color:#06202e;box-shadow:0 0 0 3px #7dd3fc 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>
|
|
{raumzeile}
|
|
<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?"):
|
|
# 🔴 Erst dekodieren, dann NICHT hochschreiben: "RAUM%3DDiele%20OG" wurde sonst zu
|
|
# "RAUM%3DDIELE%20OG" — der Marker war weder lesbar noch als Raum erkennbar.
|
|
name = urllib.parse.unquote(self.path.split("type=", 1)[1].split("&")[0])
|
|
if not name.startswith("RAUM="):
|
|
name = name.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()
|