diff --git a/.gitignore b/.gitignore index aca543d..e444e77 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/nas-vlan20/inspect_*.json routeros/state-backups/ routeros/ap1-wg-hub.privkey routeros/tp15-core-cutover-intended.rsc +docs/tp6-marker/.current-run diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index e71147b..b231d14 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -67,9 +67,10 @@ ab jetzt bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)* **Nächster Schritt (Stand 2026-08-21 abends): TP25 — Steering-Test in den Schlafräumen.** > 📋 **Einstieg: [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md)** — dort steht > der verifizierte Ist-Stand, die Messfallen und der nächste Test. -> ⚠️ **Auf `crs326-core` ist eine Config-Transaktion OFFEN** (`TEST-ether3-trunk-ap1-sideboard`, -> Frist 2026-09-04): ap1 hängt testweise am Sideboard, `ether3` wurde dafür zum Trunk. Die Compliance -> ist deshalb **absichtlich** nicht 92/92 — `python3 nautobot/gc-transaktion.py status`. +> ✅ **`crs326-core` ist transaktionsfrei (2026-08-22).** `TEST-ether3-trunk-ap1-sideboard` und +> `TEST-infra-ssid-matter-pairing` sind beide zurückgerollt und geschlossen; ap1 hängt wieder auf +> `ether15`, `ether3` ist wieder untagged VLAN40. Ein gemeldeter Drift ist ab jetzt **echt**, nicht +> beabsichtigt — `python3 nautobot/gc-transaktion.py status`. > 🆕 **TP29** (Anwesenheitserkennung fürs Energiesparen) ist als Idee angelegt, nicht auf dem Pfad. > 📌 **Die TP6-Prämisse ist am 2026-08-20/21 gemessen** (Runbook §5): formal Fall B (+28…+47 ms unter > Dauer-Upload), aber im Videocall **nicht wahrnehmbar** — kein einziger Problem-Klick über sechs @@ -1364,8 +1365,8 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". > ⇒ **Fund E** (`signal-range`-Boden am Manager) bleibt unverändert die richtige Maßnahme und ist nach > dem Rückbau der **oberste offene Punkt** in TP25 — nicht erledigt, nicht gegenstandslos. > 📋 **Übergabe: [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md).** -> ⚠️ **Auf `crs326-core` ist eine Config-Transaktion OFFEN** (`TEST-ether3-trunk-ap1-sideboard`, Scope -> `bridge-vlan`, Frist 2026-09-04) — die Compliance ist deshalb **absichtlich** nicht 92/92. +> ✅ **Transaktion `TEST-ether3-trunk-ap1-sideboard` am 2026-08-22 zurückgerollt und geschlossen** +> (Scope `bridge-vlan`) — `ether3` ist wieder untagged VLAN40, Compliance zurück auf 92/92. > **Und das passiert TROTZ aktivem 802.11k/v und FT** — live geprüft: `st-main` mit `rrm=yes`/`wnm=yes`, > `ft-over-ds=yes`. Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist damit **historisch**. > **Reihenfolge der Maßnahmen** (Doku §6, respektiert die User-Randbedingung „kein neuer AP, solange diff --git a/docs/tp25-pin-s22.sh b/docs/tp25-pin-s22.sh new file mode 100755 index 0000000..6c96f9a --- /dev/null +++ b/docs/tp25-pin-s22.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# TP25 — einen Client per access-list auf EINEN AP festnageln. +# +# Wozu: die Gehweg-Messungen von 08-20/21 zeigen den Sticky-Client (Fund B) und das +# Band-Ping-Pong (Fund C) — aber sie zeigen NICHT, wie gut ein einzelner AP eine Wohnung +# allein ausleuchtet, weil der Client waehrend des Wegs wechselt. Wer die Abdeckung von +# ap1 und ap2 vergleichen will, muss den Wechsel vorher ausschliessen. +# +# Mechanik: am MANAGER (crs326-core) wird der Client auf den Radios des jeweils ANDEREN +# AP mit action=reject abgewiesen. Uebrig bleibt genau ein AP. Die Baender dieses AP +# bleiben bewusst BEIDE offen — gepinnt wird der Standort, nicht das Band. +# +# bash docs/tp25-pin-s22.sh ap2 # nur ap2 erlaubt (ap1 abgewiesen) +# bash docs/tp25-pin-s22.sh ap1 # nur ap1 erlaubt (ap2 abgewiesen) +# bash docs/tp25-pin-s22.sh off # Pin loesen, Normalbetrieb +# bash docs/tp25-pin-s22.sh status # Regeln + aktuelle Anmeldung zeigen +# bash docs/tp25-pin-s22.sh map # cap-wifiN -> apX/Band, LIVE vom Manager +# bash docs/tp25-pin-s22.sh which # auf welchem AP der Pin gerade steht (ap1|ap2|off) +# +# 🔴 Die access-list steht unter KEINER ComplianceRule (Stand 2026-08-22: es gibt +# wifi-configuration/-datapath/-security/-steering/-provisioning/-channel/-cap/-capsman, +# aber keine fuer access-list). Dieser Change erzeugt deshalb KEINEN Drift und wird von +# Nautobot auch nicht bemerkt. Genau darum traegt jede Regel den Kommentar TP25-PIN-S22 +# und das Skript raeumt vor jedem Setzen auf: es gibt keine zweite Instanz, die das tut. +# Wenn Fund E (signal-range-Boden) dauerhaft kommt, gehoert diese Luecke geschlossen. +# +# ⚠️ Android randomisiert die MAC je SSID. Die hier gepinnte Adresse gilt fuer +# dirtyvader-main; wechselt das Telefon die SSID oder wuerfelt neu, greift der Pin +# still nicht mehr. Kontrolle ist immer `status`, nie die Annahme. + +set -uo pipefail + +MAC="${TP25_PIN_MAC:-2A:0D:B0:5D:AD:A7}" +TAG="TP25-PIN-S22" +ROUTER_IP="${TP25_MANAGER_IP:-10.40.99.1}" + +# 🔴 Die cap-wifiN-Nummern werden vom Manager DYNAMISCH vergeben, in der Reihenfolge, in der +# sich die CAPs anmelden. Ein fest verdrahtetes Mapping ist damit nur so lange richtig, bis +# ein AP einmal neu bootet oder umgesteckt wird -- und es faellt still falsch aus, weil die +# Zahlen weiter existieren. (Genau so standen sie am 2026-08-22 in docs/tp6-marker/server.py +# und docs/tp25-gehweg-auswertung.py verkehrt herum: Baender richtig, ap1/ap2 vertauscht.) +# Deshalb wird hier NICHTS gecacht -- die Zuordnung kommt bei jedem Aufruf vom Geraet. + +ZIEL="${1:-status}" + +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; } + +ros() { + sshpass -p "$PW" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 \ + "admin@${ROUTER_IP}" "$1" 2>&1 | sed "s/${PW//\//\\/}/***/g" +} + +# cap-wifiN -> "apX Band" -- live, ohne Cache. Ausgabe je Zeile: cap-wifi3=ap1/5GHz +karte() { + ros ':foreach i in=[/interface wifi find where !master-interface] do={:local c [/interface wifi get $i cap]; :local f [/interface wifi get $i channel.frequency]; :local b "2,4GHz"; :if ($f > 3000) do={:set b "5GHz"}; :put ([/interface wifi get $i name] . "=" . [:pick $c 0 [:find $c "@"]] . "/" . $b)}' \ + | tr -d '\r' | grep '^cap-wifi[0-9]*=' +} + +# Auf welchem AP steht der Pin? Ergibt sich aus den Regeln: abgewiesen wird der ANDERE AP. +welcher() { + local m; m="$(karte)" + local regeln; regeln="$(ros "/interface wifi access-list print terse where comment=\"$TAG\"")" + [[ -z "${regeln//[[:space:]]/}" ]] && { echo off; return; } + local blockiert; blockiert="$(echo "$regeln" | grep -o 'interface=cap-wifi[0-9]*' | cut -d= -f2)" + local aps_blockiert; aps_blockiert="$(for i in $blockiert; do echo "$m" | grep "^$i=" | cut -d= -f2 | cut -d/ -f1; done | sort -u)" + # Uebrig bleibt der AP, der NICHT blockiert ist. + for ap in $(echo "$m" | cut -d= -f2 | cut -d/ -f1 | sort -u); do + echo "$aps_blockiert" | grep -qx "$ap" || { echo "$ap"; return; } + done + echo off +} + +# Die Interfaces EINES AP -- live ermittelt, nicht geraten. +ifaces_von() { + karte | grep "=$1/" | cut -d= -f1 +} + +aufraeumen() { + ros "/interface wifi access-list remove [find where comment=\"$TAG\"]" >/dev/null +} + +# Der Reject greift erst bei der NAECHSTEN Anmeldung. Ohne Kick bleibt das Telefon +# u.U. minutenlang auf dem verbotenen AP haengen -- und der Rundgang misst dann nichts. +kick() { + ros "/interface wifi registration-table remove [find where mac-address=\"$MAC\"]" >/dev/null +} + +zeigen() { + echo "--- Regeln ($TAG) ---" + local r; r="$(ros "/interface wifi access-list print terse where comment=\"$TAG\"")" + if [[ -z "${r//[[:space:]]/}" ]]; then echo " (keine -- Pin ist AUS)"; else echo "$r" | sed 's/^/ /'; fi + echo "--- Anmeldung $MAC ---" + local a; a="$(ros "/interface wifi registration-table print terse where mac-address=\"$MAC\"")" + if [[ -z "${a//[[:space:]]/}" ]]; then echo " (nicht angemeldet)"; else echo "$a" | sed 's/^/ /'; fi +} + +case "$ZIEL" in + ap1|ap2) + ANDERER="ap1"; [[ "$ZIEL" == "ap1" ]] && ANDERER="ap2" + SPERREN="$(ifaces_von "$ANDERER" | tr '\n' ' ')" + if [[ -z "${SPERREN//[[:space:]]/}" ]]; then + echo "Keine Interfaces fuer $ANDERER gefunden -- haengt der AP am Manager? Abbruch." >&2; exit 1 + fi + aufraeumen + for i in $SPERREN; do + ros "/interface wifi access-list add mac-address=$MAC interface=$i action=reject comment=\"$TAG\"" >/dev/null + done + echo "S22 -> nur $ZIEL ($ANDERER abgewiesen auf: $SPERREN)"; kick ;; + off) + aufraeumen; echo "Pin geloest -- S22 darf wieder ueberall."; kick ;; + status) ;; + map) karte; exit 0 ;; + which) welcher; exit 0 ;; + *) echo "Aufruf: $0 {ap1|ap2|off|status|map|which}" >&2; exit 2 ;; +esac + +[[ "$ZIEL" == "status" ]] || sleep 6 +zeigen diff --git a/docs/tp25-walk-messung.sh b/docs/tp25-walk-messung.sh index 1fe3824..e344dd6 100755 --- a/docs/tp25-walk-messung.sh +++ b/docs/tp25-walk-messung.sh @@ -28,6 +28,12 @@ HERE="$(cd "$(dirname "$0")/.." && pwd)" mkdir -p "$RUN_DIR" [[ -f "$MARKERS" ]] || echo "ts_epoch,ts_local,marker" > "$MARKERS" +# Zeiger fuer die Klick-Konsole (docs/tp6-marker/server.py). Die laeuft als Dauer-Preview +# auf Port 3031 und kennt dieses Verzeichnis sonst nicht -- ihre Marker landeten dann im +# Lauf von gestern, waehrend der Sampler hierher schreibt. Beides fiel erst bei der +# Auswertung auf, wenn die Raummarken fehlten. +printf '%s\n' "$RUN_DIR" > "$HERE/docs/tp6-marker/.current-run" + # Frueheren Lauf beiseitelegen, statt ihn zu ueberschreiben. if [[ -f "$RUN_DIR/sampler.csv" ]]; then ARCHIV="$RUN_DIR/archiv-$(date +%H%M%S)"; mkdir -p "$ARCHIV" diff --git a/docs/tp6-marker/server.py b/docs/tp6-marker/server.py index 7496ca0..4816e88 100644 --- a/docs/tp6-marker/server.py +++ b/docs/tp6-marker/server.py @@ -16,21 +16,54 @@ Zwei Aufgaben: - 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). +🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der AP-Pin (TP25) schreibt + access-list-Regeln auf den WLAN-Manager. Die Seite bindet auf 0.0.0.0 und kennt keine + Authentifizierung — wer in VLAN10 steht, kann den Pin umschalten. Das ist fuer die Dauer + einer Messung vertretbar und sonst nicht: den Server nach dem Rundgang beenden. Der Pin + selbst ist harmlos genug (er betrifft eine MAC), aber die Regel ist es nicht, die als + naechstes hier landen koennte. + +Bindet auf 0.0.0.0 (Bedienung vom iPhone/iPad). """ import json import os import signal +import subprocess +import threading import urllib.parse import time -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -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")) +# 🔴 Das Laufverzeichnis wird NICHT mehr beim Start eingefroren. Grund (2026-08-22): die +# Konsole laeuft als Dauer-Preview auf Port 3031, der Messlauf startet spaeter und legt +# ein neues Verzeichnis an. Vorher schrieb der Server dann brav Marker in den Lauf von +# GESTERN, waehrend der Sampler ins neue Verzeichnis schrieb -- die beiden Zeitachsen +# waren hinterher nicht mehr zusammenzubringen, und man sah es der Seite nicht an. +# Jetzt hinterlaesst tp25-walk-messung.sh einen Zeiger, dem die Konsole folgt. +FALLBACK_RUN_DIR = ("/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/" + "0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2") +POINTER = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".current-run") + + +def run_dir(): + d = os.environ.get("TP6_RUN_DIR") + if d: + return d + try: + z = open(POINTER).read().strip() + if z and os.path.isdir(z): + return z + except Exception: + pass + return FALLBACK_RUN_DIR + + +def _p(name): + return os.path.join(run_dir(), name) + + +def sampler_pfad(): + return os.environ.get("TP6_SAMPLER", _p("sampler.csv")) PORT = int(os.environ.get("TP6_PORT", "3031")) # Raeume fuer die Gehweg-/Dead-Spot-Kartierung. Reihenfolge = Reihenfolge auf der Seite. @@ -40,16 +73,14 @@ ROOMS = [r.strip() for r in os.environ.get( "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") +def markers_pfad(): + d = run_dir() + os.makedirs(d, exist_ok=True) + pfad = os.path.join(d, "markers.csv") + if not os.path.exists(pfad): + with open(pfad, "w") as fh: + fh.write("ts_epoch,ts_local,marker\n") + return pfad # name, label, css-Klasse, in welchen Modi sichtbar BUTTONS = [ @@ -76,9 +107,9 @@ DEFAULT_STATE = { def read_state(): try: - with open(STATE) as fh: + with open(_p("phase.json")) as fh: st = json.load(fh) - st["driver"] = (time.time() - os.path.getmtime(STATE)) < 20 + st["driver"] = (time.time() - os.path.getmtime(_p("phase.json"))) < 20 return st except Exception: st = dict(DEFAULT_STATE) @@ -87,14 +118,14 @@ def read_state(): def write_marker(name): - with open(MARKERS, "a") as fh: + with open(markers_pfad(), "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()] + rows = [r.strip() for r in open(markers_pfad()) if r.strip()] if len(rows) < 2: return "—" p = rows[-1].split(",") @@ -106,7 +137,7 @@ def last_marker(): 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()]): + for row in reversed([r.strip() for r in open(markers_pfad()) 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])) @@ -118,7 +149,7 @@ def current_room(): 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()] + rows = [r.strip() for r in open(sampler_pfad()) if r.strip()] if len(rows) < 2: return {} row = dict(zip(rows[0].split(","), rows[-1].split(","))) @@ -137,7 +168,7 @@ 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()] + pids = [int(p) for p in open(_p("flows.pid")).read().split() if p.strip().isdigit()] except Exception: pids = [] for pid in pids: @@ -146,12 +177,95 @@ def kill_flows(): killed += 1 except Exception: pass - open(ABORT_FLAG, "w").close() + open(_p("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"} +# 🔴 Hier stand bis 2026-08-22 eine fest verdrahtete Tabelle — und zwar mit VERTAUSCHTEN +# APs (cap-wifi1/2 als ap1 statt ap2). Die Baender stimmten, die Identitaet nicht. Die +# cap-wifiN-Nummern vergibt der Manager dynamisch in der Anmeldereihenfolge der CAPs; +# jede Konstante dazu ist ein Fehler, der auf den naechsten AP-Reboot wartet und dann +# still falsch beschriftet. Deshalb kommt die Zuordnung jetzt vom Geraet. +PIN_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tp25-pin-s22.sh") +_apmap = {"stand": 0.0, "karte": {}} +_aplock = threading.Lock() + + +def ap_map(): + """cap-wifiN -> 'ap1 / 5 GHz'. Live vom Manager, 60 s gecacht.""" + with _aplock: + if time.time() - _apmap["stand"] < 60 and _apmap["karte"]: + return _apmap["karte"] + karte = {} + try: + out = subprocess.run(["bash", PIN_SH, "map"], capture_output=True, text=True, + timeout=20).stdout + for zeile in out.splitlines(): + if "=" not in zeile: + continue + iface, wert = zeile.strip().split("=", 1) + ap, _, band = wert.partition("/") + karte[iface] = "%s / %s" % (ap, band.replace("GHz", " GHz")) + except Exception: + pass + with _aplock: + if karte: + _apmap.update(stand=time.time(), karte=karte) + return _apmap["karte"] + + +# Pin-Zustand. Das Umschalten dauert ~8 s (Regeln + Kick + Nachlesen), deshalb laeuft es +# in einem Thread und die Seite zeigt solange "schaltet um". +_pin = {"ziel": None, "busy": False, "msg": "", "stand": 0.0} + + +def pin_lesen(): + if _pin["busy"] or time.time() - _pin["stand"] < 20: + return _pin["ziel"] + try: + _pin["ziel"] = subprocess.run(["bash", PIN_SH, "which"], capture_output=True, + text=True, timeout=20).stdout.strip() or None + _pin["stand"] = time.time() + except Exception: + pass + return _pin["ziel"] + + +def pin_setzen(ziel): + def lauf(): + try: + r = subprocess.run(["bash", PIN_SH, ziel], capture_output=True, text=True, timeout=60) + _pin["msg"] = (r.stdout.strip().splitlines() or [""])[0] + except Exception as e: + _pin["msg"] = "Fehler: %s" % e + finally: + _pin["busy"] = False + _pin["stand"] = 0.0 + pin_lesen() + _pin["busy"] = True + _pin["msg"] = "schaltet um …" + # Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher + # nicht sagen, welche Messpunkte zu welchem AP gehoeren. + write_marker("PIN=%s" % ziel) + threading.Thread(target=lauf, daemon=True).start() + + +def lokale_adressen(): + """Alle IPv4-Adressen dieses Rechners ausser Loopback/Link-Local, als (iface, ip).""" + treffer = [] + try: + out = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=10).stdout + iface = "?" + for zeile in out.splitlines(): + if zeile and not zeile[0].isspace(): + iface = zeile.split(":", 1)[0] + elif zeile.strip().startswith("inet "): + ip = zeile.split()[1] + if not ip.startswith(("127.", "169.254.")): + treffer.append((iface, ip)) + except Exception: + pass + return treffer def fmt_bps(v): @@ -168,7 +282,20 @@ def render(st): mode = st.get("mode", "wartet") banner = "" - if not st.get("driver"): + alarm = False # nur DIESER Zustand loest Ton + Vibration aus, nicht jeder Banner + # 🔴 Der wichtigste Alarm der Seite, seit es den AP-Pin gibt (2026-08-22): ein vom Pin + # ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von einem Funkloch zu + # unterscheiden -- und der Manager protokolliert die Abweisung auf info-Ebene nicht. + # Android quittiert sie mit "Verbindung fehlgeschlagen" und WAEHLT DAS NETZ AB, danach + # misst der restliche Rundgang nur noch Stille. Deshalb wird "haengt nirgends" hier + # nicht klein in der WLAN-Zeile gezeigt, sondern schreit. + if pin_lesen() in ("ap1", "ap2") and w.get("iface") == "absent" and w.get("age", 999) < 30: + alarm = True + banner = ('') + elif not st.get("driver"): banner = ('') elif mode == "langzeit": @@ -185,9 +312,35 @@ def render(st): wlan = "—" if w.get("iface"): wlan = "%s · %s dBm · tx %s / rx %s" % ( - AP.get(w["iface"], w["iface"]), w.get("signal", "?"), + ap_map().get(w["iface"], w["iface"]), w.get("signal", "?"), fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps"))) + ziel = pin_lesen() + if _pin["busy"]: + pinstatus = "⏳ %s" % _pin["msg"] + elif ziel in ("ap1", "ap2"): + pinstatus = "S22 haengt fest auf %s" % ziel + elif ziel == "off": + pinstatus = "kein Pin — S22 roamt frei" + else: + pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)" + pinkarte = ( + '
' + '
AP-Pin S22
' + '
%s
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' % (1 if alarm else 0, 1 if _pin["busy"] else 0, ziel or "?", + pinstatus, + " hier" if ziel == "ap1" else "", + " hier" if ziel == "ap2" else "") + ) + raum, seit = current_room() raumzeile = "" if mode in ("langzeit", "messung"): @@ -208,7 +361,7 @@ def render(st): 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")), + raumzeile=raumzeile, pinkarte=pinkarte, 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, ) @@ -250,6 +403,7 @@ form{{margin:0}} {countdown} {prog} +{pinkarte} {raumzeile}
WLAN {wlan} WAN {wan} @@ -265,23 +419,83 @@ window.onerror = function(m, s, l, c){{ }}; function send(ev, name){{ try{{ - ev.preventDefault(); + ev.preventDefault(); unlock(); 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 }} +// 🔊 Signalton. Browser lassen Audio erst nach einer echten Nutzergeste zu -- der erste +// Button-Druck schaltet ihn frei, deshalb haengt unlock() an jedem Klick. Auf dem S22 +// kommt Vibration dazu; iOS kennt navigator.vibrate nicht, das faellt still aus. +var AC = null; +function unlock(){{ + try{{ + if(!AC) AC = new (window.AudioContext || window.webkitAudioContext)(); + if(AC.state === 'suspended') AC.resume(); + }}catch(e){{}} +}} +function ton(hz, ms, wann){{ + try{{ + if(!AC) return; + var o = AC.createOscillator(), g = AC.createGain(); + o.type = 'sine'; o.frequency.value = hz; + g.gain.setValueAtTime(0.001, AC.currentTime + wann); + g.gain.exponentialRampToValueAtTime(0.35, AC.currentTime + wann + 0.02); + g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + wann + ms/1000); + o.connect(g); g.connect(AC.destination); + o.start(AC.currentTime + wann); o.stop(AC.currentTime + wann + ms/1000 + 0.05); + }}catch(e){{}} +}} +function alarmton(){{ ton(880,180,0); ton(660,180,0.24); ton(880,260,0.48); + try{{ navigator.vibrate && navigator.vibrate([200,120,200,120,320]); }}catch(e){{}} }} +function okton(){{ ton(1320,90,0); ton(1760,120,0.11); + try{{ navigator.vibrate && navigator.vibrate(90); }}catch(e){{}} }} + +var letzterAlarm = 0, letzterBusy = 0, alarmTimer = null; +function signale(){{ + var sig = document.getElementById('sig'); if(!sig) return; + var alarm = sig.dataset.alarm === '1', busy = sig.dataset.busy === '1'; + if(alarm && !letzterAlarm){{ + alarmton(); + // Solange nichts geschieht, alle 12 s erinnern -- ein einmaliger Piep in der Hosentasche + // waehrend des Gehens ist kein Alarm. + if(!alarmTimer) alarmTimer = setInterval(alarmton, 12000); + }} + if(!alarm && alarmTimer){{ clearInterval(alarmTimer); alarmTimer = null; }} + if(!busy && letzterBusy) okton(); // Umschalten fertig + letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0; +}} + +function sendpin(ev, ap){{ + try{{ + ev.preventDefault(); unlock(); + // 🔴 Der Klick kommt vom S22 SELBST -- also von genau dem Geraet, das der Pin gleich vom + // WLAN wirft. Die Antwort auf dieses fetch() kommt deshalb womoeglich nie an, und die + // Seite kann sich nicht mehr nachladen. Die Anweisung muss JETZT auf dem Schirm + // stehen, solange die Verbindung noch steht. + var inst = document.getElementById('pininst'); + if(inst) inst.innerHTML = '⏳ schalte auf ' + ap + ' …
' + + 'Dieses Telefon fliegt jetzt aus dem WLAN. Wenn die Seite haengt: in den ' + + 'WLAN-Einstellungen wieder auf dirtyvader-main verbinden, dann laedt sie von selbst.'; + okton(); + fetch('/pin?ap=' + encodeURIComponent(ap), {{method:'POST'}}).catch(function(){{}}); + setTimeout(refresh, 3000); setTimeout(refresh, 9000); setTimeout(refresh, 20000); + return false; + }}catch(e){{ return true; }} +}} 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 karten = d.querySelectorAll('.card'), alte = document.querySelectorAll('.card'); + for(var i = 0; i < karten.length && i < alte.length; i++) alte[i].replaceWith(karten[i]); 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(); + if(!nban && oban) oban.remove(); + tick(); signale(); }}).catch(function(){{}}); }} function tick(){{ @@ -292,6 +506,8 @@ function tick(){{ }} var mr = document.getElementById('metaref'); if(mr) mr.remove(); // JS lebt -> kein Vollreload setInterval(tick, 1000); setInterval(refresh, 3000); +document.addEventListener('click', unlock, {{once:false}}); +signale(); // 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(); }}); @@ -328,10 +544,24 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path.startswith("/jserr"): - with open(os.path.join(RUN_DIR, "jserrors.log"), "a") as fh: + with open(_p("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("/pin?"): + ap = urllib.parse.unquote(self.path.split("ap=", 1)[1].split("&")[0]) + if ap not in ("ap1", "ap2", "off"): + self._send(400, json.dumps({"error": "ap muss ap1, ap2 oder off sein"})) + return + if _pin["busy"]: + self._send(409, json.dumps({"error": "laeuft schon"})) + return + pin_setzen(ap) + if "text/html" in (self.headers.get("Accept") or ""): + self._back() + else: + self._send(200, json.dumps({"ok": True, "ziel": ap})) + 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. @@ -342,7 +572,7 @@ class Handler(BaseHTTPRequestHandler): write_marker("ABORT_killed=%d" % kill_flows()) else: if name == "CALL_START": - open(GO_FLAG, "w").close() + open(_p("go.flag"), "w").close() write_marker(name) if "text/html" in (self.headers.get("Accept") or ""): self._back() @@ -356,6 +586,20 @@ class Handler(BaseHTTPRequestHandler): 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() + # 🔴 Hier stand eine HARTKODIERTE IP (10.40.10.183) -- die des MacBook Air. Auf dem Mac + # mini ist es 10.40.10.82, und am 2026-08-22 wurde die falsche Adresse ans Telefon + # weitergegeben: "geht nicht" bei einem Server, der einwandfrei auf 0.0.0.0 lauschte. + # Eine Adresse, die man ansagt, ohne sie gelesen zu haben, ist keine Adresse. + # Deshalb kommt sie jetzt vom Interface -- und zwar ALLE, mit Kennzeichnung. + print("Konsole lokal: http://localhost:%d/" % PORT) + for name, ip in lokale_adressen(): + print(" vom Telefon: http://%s:%d/ (%s)" % (ip, PORT, name)) + if not lokale_adressen(): + print(" ⚠️ keine LAN-Adresse gefunden -- vom Telefon nicht erreichbar") + print("Marker -> %s" % markers_pfad()) + for iface, wo in sorted(ap_map().items()): + print(" %-14s %s" % (iface, wo)) + # 🔴 Threading ist Pflicht, seit der Pin dazugekommen ist: das Umschalten redet ueber SSH + # mit dem Manager und braucht ~8 s. Mit dem alten HTTPServer (single-threaded) haette + # das die ganze Konsole blockiert -- inklusive Not-Aus. + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()