AP-Pin fuer den Rundgang -- und drei Dinge, die dabei still falsch waren

TP25: das S22 laesst sich jetzt per access-list am Manager auf EINEN AP
festnageln (docs/tp25-pin-s22.sh, Umschalter in der Klick-Konsole). Damit
werden Einzel-AP-Abdeckungskarten messbar, statt dass der Client mitten im
Weg wechselt. Gepinnt wird der Standort, nicht das Band -- Band-Ping-Pong
(Fund C) bleibt damit ein echter Befund und wird nicht wegkonfiguriert.

Beim Bauen fielen drei Fehler auf, die alle dieselbe Form haben: eine
Konstante, die einmal richtig war.

1. Die AP-Zuordnung war VERTAUSCHT. server.py und tp25-gehweg-auswertung.py
   fuehren cap-wifi1/2 als ap1 -- live ist es ap2 (belegt ueber cap=,
   configuration= und Kanal). Baender stimmten, Identitaet nicht. Die
   cap-wifiN-Nummern vergibt der Manager dynamisch in der Anmeldereihenfolge
   der CAPs, jede feste Tabelle dazu wartet auf den naechsten AP-Reboot.
   server.py liest sie jetzt vom Geraet; die Auswertung ist NOCH NICHT
   angefasst -- offen ist, ob die Funde vom 08-21 mit vertauschten AP-Namen
   notiert sind.

2. Das Laufverzeichnis war beim Serverstart eingefroren. Die Konsole laeuft
   als Dauer-Preview, der Messlauf legt spaeter ein neues Verzeichnis an --
   Marker waeren in den Lauf von gestern gewandert, waehrend der Sampler ins
   neue schreibt. tp25-walk-messung.sh hinterlaesst jetzt einen Zeiger.

3. Das Startbanner nannte eine hartkodierte IP (10.40.10.183 = MacBook Air).
   Auf dem Mac mini ist es 10.40.10.82, und genau die falsche Adresse ging
   ans Telefon: "geht nicht" bei einem Server, der sauber auf 0.0.0.0 lauschte.
   Es werden jetzt die real gefundenen Interface-Adressen gedruckt.

Ausserdem, aus dem Test heraus:
- Ein vom Pin ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von
  einem Funkloch zu unterscheiden, und der Manager protokolliert ihn auf
  info-Ebene nicht. Android quittiert ihn mit "Verbindung fehlgeschlagen" und
  waehlt das Netz ab -- danach misst der Rest des Rundgangs Stille. Die Konsole
  schlaegt deshalb mit Vollbanner, Ton und Vibration Alarm, sobald das gepinnte
  Geraet nirgends haengt.
- Die Konsole laeuft diesmal auf dem S22 selbst, also auf genau dem Geraet, das
  der Pin gleich vom WLAN wirft. Die Anweisung zum Wiederverbinden erscheint
  deshalb clientseitig VOR dem Abbruch.
- ThreadingHTTPServer: der Pin braucht ~8 s ueber SSH und haette sonst die
  ganze Seite blockiert, Not-Aus inklusive.

Governance: /interface wifi access-list steht unter KEINER ComplianceRule --
der Change erzeugt keinen Drift und laesst sich nicht als Transaktion fuehren.
Fuer eine Messung vertretbar; relevant wird es, wenn Fund E (signal-range)
dauerhaft dort landet.

teilprojekte.md: die beiden Hinweise auf die offene Transaktion
TEST-ether3-trunk-ap1-sideboard nachgezogen -- sie ist seit 08-22
zurueckgerollt, crs326-core ist transaktionsfrei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:21:55 +02:00
parent 1f0136b226
commit 5231f7b69b
5 changed files with 417 additions and 45 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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

120
docs/tp25-pin-s22.sh Executable file
View File

@@ -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

View File

@@ -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"

View File

@@ -16,21 +16,54 @@ Zwei Aufgaben:
- 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).
🔴 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 = ('<div class="banner warn">📵 S22 HÄNGT NIRGENDS — Display an und wieder auf '
'dirtyvader-main verbinden. Der Pin wirft es vom gesperrten AP; Android '
'wählt das Netz dann von selbst ab. <b>Nicht weitergehen</b>, sonst misst '
'du Stille statt Abdeckung.</div>')
elif 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":
@@ -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 <b>%s</b>" % ziel
elif ziel == "off":
pinstatus = "kein Pin — S22 roamt frei"
else:
pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)"
pinkarte = (
'<div class="card"><span id="sig" data-alarm="%d" data-busy="%d" data-pin="%s"></span>'
'<div class="phase">AP-Pin S22</div>'
'<div class="inst" id="pininst">%s</div><div class="raster">'
'<form method="post" action="/pin?ap=ap1" onsubmit="return sendpin(event,\'ap1\')">'
'<button class="raum%s" type="submit">ap1</button></form>'
'<form method="post" action="/pin?ap=ap2" onsubmit="return sendpin(event,\'ap2\')">'
'<button class="raum%s" type="submit">ap2</button></form>'
'</div>'
'<form method="post" action="/pin?ap=off" onsubmit="return sendpin(event,\'off\')">'
'<button class="cam" style="margin-top:8px" type="submit">Pin lösen</button></form>'
'</div>' % (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}
</div>
{pinkarte}
{raumzeile}
<div class="live">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 <b>' + ap + '</b> …<br>' +
'<b>Dieses Telefon fliegt jetzt aus dem WLAN.</b> Wenn die Seite haengt: in den ' +
'WLAN-Einstellungen wieder auf <b>dirtyvader-main</b> 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()