TP25: Klick-Konsole auf N Clients + Pin-UI

Die Konsole (docs/tp6-marker/server.py) war beim Multi-Pin-Umbau nicht
mitgezogen worden: hartkodiert auf 2 Clients, pinnte ueber die alte
Gegen-AP-Automatik. Jetzt:

- Client-Liste aus geraete.txt (schreibt tp25-walk-messung.sh), Fallback
  auf die zwei alten MACs fuer Alt-Laeufe.
- Kein eigenes SSH mehr: je Client letzte Zeile von sampler-<name>.csv
  (AP/Signal) + Ping-Verlust aus walk-ping-<name>.csv.
- N Client-Karten; Alarm, wenn ein gepinnter Client nirgends haengt.
- Pin-UI: je Client AP/Band waehlen, set/off/verify via tp25-pin-multi.sh
  (POST /pinmulti). Alte 2-Client-Automatik (entscheide/pin_setzen/auto)
  entfernt; tp25-pin-s22.sh liefert nur noch die map.
- tp25-runde.sh loest beim Aufraeumen den Multi-Pin statt des S22-Pins.

Doku: Ist-Stand-Block + Quick-Index + Messwerkzeug-Bullets nachgezogen,
neuer Handoff 2026-09-04.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:18:07 +02:00
parent efb0f44fa7
commit 70afb165b7
5 changed files with 396 additions and 315 deletions

View File

@@ -0,0 +1,70 @@
# Handoff 2026-09-04 — TP25: Klick-Konsole auf N Clients + Pin-UI
**Kurz:** Die Klick-Konsole (`docs/tp6-marker/server.py`, Port 3031) war beim Multi-Pin-Umbau
(2026-09-04, `tp25-pin-multi.sh` + `TP25_PHONES`) **nicht** mitgezogen worden — sie zeigte weiter
hartkodiert zwei Clients (iPhone/S22) und pinnte ueber die alte Gegen-AP-Automatik. Jetzt ist sie
auf **N Clients** generalisiert und hat eine **Pin-UI**: Signal/AP/Ping-Verlust je Client live,
Pinnen band-genau direkt in der Konsole. Die alte 2-Client-Automatik ist entfernt.
## Was umgebaut wurde
`docs/tp6-marker/server.py` (Hauptumbau):
- **Client-Liste aus Datei statt hartkodiert:** `geraete()` liest `geraete.txt` aus dem
Laufverzeichnis (schreibt `tp25-walk-messung.sh`, eine Zeile `name|ip|mac` je Client). Fallback
auf die zwei alten MACs fuer Alt-Laeufe.
- **Kein eigenes SSH mehr in der Konsole:** der `beobachter()`-Thread (2 MACs per SSH,
`beide-telefone.csv`) ist raus. Stattdessen liest die Konsole je Client die **letzte Zeile** von
`sampler-<name>.csv` (`sta_iface`, `sta_signal`) und den **juengsten Ping-Verlust** aus
`walk-ping-<name>.csv` (leeres `ms` = Verlust). Eine SSH-Session weniger gegen den Core-SPOF.
- **N Client-Karten** statt der festen iPhone/S22-Zeile: Name, AP (via `ap_map()`), Signal dBm,
Ping-Verlust der letzten ~30 s. **Alarm**, wenn ein **gepinnter** Client `sta_iface == "absent"`
ist → „<name> HAENGT NIRGENDS — nicht weitergehen" (Nachfolger des alten S22-Alarms).
- **Pin-UI** (Karte „Pinnen (Multi-Pin)"): je Client ein `<select>` mit `frei` / `apX/5GHz` /
`apX/2,4GHz` (Ziele aus `ap_map()` abgeleitet) plus drei Aktionen:
- **„Pin setzen"** → baut `TP25_PINSPEC="name|mac|ap|band;…"` aus den Nicht-`frei`-Auswahlen und
ruft `bash docs/tp25-pin-multi.sh set` im Thread.
- **„Pin loesen"** → `bash docs/tp25-pin-multi.sh off`.
- **„verify"** → `bash docs/tp25-pin-multi.sh verify`, Ausgabe als Statuszeile.
Neuer Endpunkt `POST /pinmulti` (analog zum alten `/pin`).
- **Entfernt:** `IPHONE_MAC`/`S22_MAC`, `_live`/`_auto`, `entscheide()`, `pin_lesen()`/
`pin_setzen()` (via `tp25-pin-s22.sh`), die ap1/ap2/auto-Buttons und der `PIN_SH`-Aufruf.
`tp25-pin-s22.sh` bleibt als Skript erhalten (liefert `map`), wird aber von der Konsole nicht
mehr zum Pinnen aufgerufen.
`docs/tp25-runde.sh`:
- `aufraeumen()` loest jetzt `tp25-pin-multi.sh off` statt `tp25-pin-s22.sh off` (Kommentar
angepasst: die Konsole pinnt nicht mehr automatisch).
## Bedienung (Messablauf)
```
bash docs/tp25-runde.sh [name] # Konsole (3031) + Messung, Adresse fuer das Telefon
```
1. Am Telefon die Konsole oeffnen, einmal irgendwo tippen (schaltet den Signalton frei).
2. In der Konsole **pinnen**: je Client AP/Band waehlen, „Pin setzen". Clients ohne Ziel bleiben
frei (das Skript raeumt vorher alle `TP25-PINM-*`-Regeln ab).
3. Losgehen. 🚶 beim Losgehen, 📍 beim Stehenbleiben, Raum antippen. Alarm = ein gepinnter Client
haengt nirgends.
4. „⏹ Aufzeichnung stoppen" (oder Ctrl-C im Terminal). Der Wrapper loest den Multi-Pin beim Beenden.
Auswertung: `python3 docs/tp25-gehweg-auswertung.py --dir docs/messdaten/tp25-runde-<name>`
## Was noch offen ist (braucht die Geraete vor Ort)
- **Live-Test mit N Geraeten im Netz** (Plan-Verifikation Schritt 3): `TP25_PHONES="…" bash
docs/tp25-runde.sh test` → N Karten sichtbar, pinnen → `bash docs/tp25-pin-multi.sh verify` zeigt
die Regeln, gehen/Raeume/stoppen → Auswertung liefert die Raumtabelle mit N Geraeten.
- **Aufraeumen** (Schritt 4): nach dem Lauf `bash docs/tp25-pin-multi.sh off` — keine
`TP25-PINM-*`-Regeln am Manager zurueck.
Statisch verifiziert (ohne Geraete): AST-Parse ok, keine stale Referenzen auf die alte Automatik,
Smoke-Render mit 2 Test-Clients ok, `bash -n` auf allen drei Shell-Skripten ok.
## Gotchas (unveraendert, aus CLAUDE.md)
- **`cap-wifiN`-Karte wandert bei jedem Rejoin** — `tp25-walk-messung.sh` friert sie als
`ap-map.txt` im Messlauf ein; die Konsole beschriftet daraus.
- **Registration-Table nur am Manager** — die Sampler (`tp6-monitor-core.sh`) fragen den Core ab,
nicht die CAPs.
- **`sshpass -e`, nie `-p`** — gilt weiter fuer alle Skripte, die den Core ansprechen.

View File

@@ -27,10 +27,16 @@ Mgmt (VLAN99, Ist 2026-08-16): core `.99.1` · branch `.99.2` · ap1 `.99.11` ·
## Ist-Stand (Kurz) ## Ist-Stand (Kurz)
**Stand 2026-09-02 — live gegen die Geräte geprüft (MikroMCP/REST/SSH), nicht aus der Doku abgeschrieben.** **Stand 2026-09-04 — live gegen die Geräte geprüft (MikroMCP/REST/SSH), nicht aus der Doku abgeschrieben.**
**Compliance 140/140** (nach TP42, 2026-09-02). **Compliance 140/140** (nach TP42, 2026-09-02).
*(Die Fassung vom 2026-08-16 stand bis zum 18. hier und war an vier Stellen überholt. Wer diesen Block *(Die Fassung vom 2026-08-16 stand bis zum 18. hier und war an vier Stellen überholt. Wer diesen Block
liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)* liest, liest ihn als Erstes: er wird bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)*
- ✅ **TP25 Multi-Pin-Konsole (2026-09-04):** die Klick-Konsole (`docs/tp6-marker/server.py`) ist auf
**N Clients** generalisiert und hat eine **Pin-UI** bekommen. Sie liest die Client-Liste aus
`geraete.txt` (schreibt `tp25-walk-messung.sh`), zeigt je Client AP/Signal/Ping-Verlust aus
`sampler-<name>.csv`/`walk-ping-<name>.csv` (kein eigenes SSH mehr) und pinnt N Clients band-genau
über `tp25-pin-multi.sh` direkt in der Konsole. Die alte 2-Client-Gegen-AP-Automatik ist entfernt.
Handoff: [`docs/handoff-20260904-tp25-konsole-n-clients.md`](handoff-20260904-tp25-konsole-n-clients.md).
- ✅ **TP25 Fund E GESCHLOSSEN (2026-09-02):** die vorgemerkte Abschlussmessung ist gelaufen — - ✅ **TP25 Fund E GESCHLOSSEN (2026-09-02):** die vorgemerkte Abschlussmessung ist gelaufen —
`reauthenticating` am 09-02 nur noch **4 in 8,6 h** (alle iPhone `F2:62`, isolierte Einzel, 0 Ausfälle) `reauthenticating` am 09-02 nur noch **4 in 8,6 h** (alle iPhone `F2:62`, isolierte Einzel, 0 Ausfälle)
gegen Baseline 93 bzw. Hebel-1 **19/h**. Verlauf **218 → 40 → 4**; Vorbedingung geprüft (Core-Uptime gegen Baseline 93 bzw. Hebel-1 **19/h**. Verlauf **218 → 40 → 4**; Vorbedingung geprüft (Core-Uptime
@@ -506,7 +512,7 @@ nachmessen und dann entscheiden**. Blocker gibt es keine mehr. TP5 ist in der Na
> | TP20.1 | 🔜 | Vaultwarden → Platform-PG | — | > | TP20.1 | 🔜 | Vaultwarden → Platform-PG | — |
> | TP23 | ✅ | DNS-Policy (Stufe 1+2 ✅) | 2026-08-26 | > | TP23 | ✅ | DNS-Policy (Stufe 1+2 ✅) | 2026-08-26 |
> | TP24 | 🔜 | DC-USV | — | > | TP24 | 🔜 | DC-USV | — |
> | TP25 | ⏳ | WLAN-Stabilisierung (Kern ✅) — **Fund E ✅ geschlossen 09-02: 7.24.1 war die Ursache, nicht `ft-over-ds` (4/8,6 h, 0 Ausfälle)**; offen nur ap2 unterperformt (2 m/−80, In-situ) | 2026-09-02 | > | TP25 | ⏳ | WLAN-Stabilisierung (Kern ✅) — **Fund E ✅ geschlossen 09-02: 7.24.1 war die Ursache, nicht `ft-over-ds` (4/8,6 h, 0 Ausfälle)**; **Konsole auf N Clients + Pin-UI ✅ 09-04**; offen nur ap2 unterperformt (2 m/−80, In-situ) | 2026-09-04 |
> | TP28 | ✅ | WLAN/CAPsMAN — dritter AP `ap3` (cAP ax) **live** | 2026-08-30 | > | TP28 | ✅ | WLAN/CAPsMAN — dritter AP `ap3` (cAP ax) **live** | 2026-08-30 |
> | TP29 | 💡 | Anwesenheitserkennung (Idee) | — | > | TP29 | 💡 | Anwesenheitserkennung (Idee) | — |
> | TP30 | 🔜 | Credentials (Etappe 1 ✅, Rest gebündelt) | — | > | TP30 | 🔜 | Credentials (Etappe 1 ✅, Rest gebündelt) | — |
@@ -1858,16 +1864,20 @@ während eines Calls deployen, `/system backup` je AP vorher.
- 🔴 **Ein vom Pin abgewiesener Verbindungsversuch ist in Ping-Daten nicht von einem Funkloch zu - 🔴 **Ein vom Pin abgewiesener Verbindungsversuch ist in Ping-Daten nicht von einem Funkloch zu
unterscheiden** — und der Manager protokolliert ihn auf `info`-Ebene **gar nicht**. Android quittiert unterscheiden** — und der Manager protokolliert ihn auf `info`-Ebene **gar nicht**. Android quittiert
die Abweisung mit „Verbindung fehlgeschlagen" und **wählt das Netz ab**; danach misst der Rest des die Abweisung mit „Verbindung fehlgeschlagen" und **wählt das Netz ab**; danach misst der Rest des
Rundgangs Stille. Die Klick-Konsole schlägt deshalb Alarm, sobald das gepinnte Gerät nirgends hängt, Rundgangs Stille. Die Klick-Konsole schlägt deshalb Alarm, sobald ein **gepinnter** Client nirgends
und schreibt nach 45 s `UNERREICHBAR=apX` — denn „der Gegen-AP trägt hier nicht" **ist das Messergebnis**. hängt (`sta_iface=absent` in `sampler-<name>.csv`) — denn „der Gegen-AP trägt hier nicht" **ist das
- 📐 **Eine Runde, zwei Messpunkte** (Christophs Aufbau 2026-08-22): das frei roamende iPhone zeigt, was Messergebnis**.
ein normaler Client wählt — interessant ist der **andere**. Das S22 wird per `access-list` auf den - 📐 **Eine Runde, N Messpunkte** (seit 2026-09-04): die frei roamenden Clients zeigen, was ein normaler
Gegen-AP gezwungen ([`tp25-pin-s22.sh`](tp25-pin-s22.sh)). Gute Referenz + schlechte Sonde = die Client wählt — interessant sind die **gepinnten**. Jeder Client wird **band-genau** auf einen AP
Abdeckung steht, die Rückfallebene nicht. Memory `ref_gegen_ap_messung`. gezwungen ([`tp25-pin-multi.sh`](tp25-pin-multi.sh), `TP25_PINSPEC="name|mac|ap|band;…"`), direkt aus
der Klick-Konsole. Gute Referenz + schlechte Sonde = die Abdeckung steht, die Rückfallebene nicht.
Memory `ref_gegen_ap_messung`. *(Die alte 2-Client-`access-list`-Automatik über `tp25-pin-s22.sh` ist
mit dem Multi-Pin-Umbau entfallen; `tp25-pin-s22.sh` liefert nur noch die `map`.)*
- ▶️ **Start ohne Werkzeugkette: `bash docs/tp25-runde.sh [name]`** — legt den Lauf unter - ▶️ **Start ohne Werkzeugkette: `bash docs/tp25-runde.sh [name]`** — legt den Lauf unter
`docs/messdaten/tp25-runde-<name>/` an, friert die AP-Karte ein, startet Konsole (Port 3031) und `docs/messdaten/tp25-runde-<name>/` an, friert die AP-Karte ein, startet Konsole (Port 3031) und
Messung, sagt die **vom Interface gelesene** Adresse fürs Telefon an und löst beim Beenden den Pin. Messung, sagt die **vom Interface gelesene** Adresse fürs Telefon an. In der Konsole: **pinnen**
Auswertung: `tp25-gehweg-auswertung.py --dir …`. (N Clients je AP/Band) → **gehen** (🚶/📍) → **Räume antippen** → **stoppen**; beim Beenden löst der
Wrapper den Multi-Pin. Auswertung: `tp25-gehweg-auswertung.py --dir …`.
- ℹ️ **Und das alles passiert TROTZ aktivem 802.11k/v und FT** (`st-main` mit `rrm=yes`/`wnm=yes`, - ℹ️ **Und das alles passiert TROTZ aktivem 802.11k/v und FT** (`st-main` mit `rrm=yes`/`wnm=yes`,
`ft-over-ds=yes`, live geprüft). Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist `ft-over-ds=yes`, live geprüft). Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist
**historisch**. Bei −48…−55 dBm kostet eine Übergabe 0–2,6 s, meist unter einer halben Sekunde — **historisch**. Bei −48…−55 dBm kostet eine Übergabe 0–2,6 s, meist unter einer halben Sekunde —

View File

@@ -19,9 +19,9 @@ cd "$HERE"
NAME="${1:-$(date +%Y%m%d-%H%M)}" NAME="${1:-$(date +%Y%m%d-%H%M)}"
export TP6_RUN_DIR="$HERE/docs/messdaten/tp25-runde-$NAME" export TP6_RUN_DIR="$HERE/docs/messdaten/tp25-runde-$NAME"
export TP25_PHONE_IP="${TP25_PHONE_IP:-10.40.10.178}" # S22 -- wird gepingt # N-Geraete-Liste (name|ip|mac;...). Default = die bisherigen zwei, damit alte Aufrufe
export TP25_PHONE_MAC="${TP25_PHONE_MAC:-2A:0D:B0:5D:AD:A7}" # unveraendert weiterlaufen. Wird an tp25-walk-messung.sh durchgereicht.
export TP25_IPHONE_MAC="${TP25_IPHONE_MAC:-F2:62:4C:E1:AF:09}" export TP25_PHONES="${TP25_PHONES:-iphone|10.40.10.196|F2:62:4C:E1:AF:09;s22|10.40.10.178|2A:0D:B0:5D:AD:A7}"
mkdir -p "$TP6_RUN_DIR" mkdir -p "$TP6_RUN_DIR"
@@ -36,14 +36,6 @@ else
sleep 2 sleep 2
fi fi
# 🔴 Gegen-AP-Automatik gleich einschalten. Sie war bisher ein Klick auf der Konsole -- und
# wer ihn vergisst, geht die ganze Runde mit beiden Telefonen auf DEMSELBEN AP und merkt
# es erst bei der Auswertung (im Test am 2026-08-22 genau so passiert). Abschalten geht
# weiter jederzeit ueber den Knopf.
curl -s -X POST --max-time 10 "http://127.0.0.1:3031/auto?an=1" >/dev/null 2>&1 \
&& echo "Gegen-AP-Automatik: EIN" \
|| echo "⚠️ Automatik konnte nicht eingeschaltet werden -- auf der Konsole nachholen!"
echo echo
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"
echo " Konsole am Telefon oeffnen:" echo " Konsole am Telefon oeffnen:"
@@ -53,8 +45,8 @@ ifconfig | awk '/^[a-z0-9]+:/{i=substr($1,1,length($1)-1)}
/inet /{if ($2 !~ /^127\.|^169\.254\./) printf " http://%s:3031/ (%s)\n", $2, i}' /inet /{if ($2 !~ /^127\.|^169\.254\./) printf " http://%s:3031/ (%s)\n", $2, i}'
echo echo
echo " 1) Auf dem Telefon einmal irgendwo tippen -> schaltet den Signalton frei" echo " 1) Auf dem Telefon einmal irgendwo tippen -> schaltet den Signalton frei"
echo " 2) Gegen-AP-Automatik laeuft bereits -> S22 folgt dem iPhone spiegelverkehrt" echo " 2) In der Konsole pinnen (Multi-Pin) -> je Client AP/Band waehlen, 'Pin setzen'"
echo " 3) Losgehen. Doppelton = beide angemeldet -> Raum antippen, weiter" echo " 3) Losgehen. Raum antippen, weiter -> Alarm = gepinnter Client haengt nirgends"
echo echo
echo " Lauf: $TP6_RUN_DIR" echo " Lauf: $TP6_RUN_DIR"
echo "════════════════════════════════════════════════════════════" echo "════════════════════════════════════════════════════════════"
@@ -63,18 +55,17 @@ echo
aufraeumen() { aufraeumen() {
echo echo
echo "Runde beendet. Aufraeumen …" echo "Runde beendet. Aufraeumen …"
# 🔴 REIHENFOLGE: erst die Konsole toeten, dann den Pin loesen. Andersherum ist es ein # 🔴 REIHENFOLGE: erst die Konsole toeten, dann den Pin loesen. Die Konsole pinnt nicht
# Wettlauf, den man verliert -- die Automatik der noch laufenden Konsole sieht den # mehr automatisch (die alte Gegen-AP-Automatik ist mit dem Multi-Pin-Umbau 2026-09-04
# geloesten Pin und setzt ihn binnen zwei Sekunden wieder. Am 2026-08-22 meldete der # entfallen), aber sie haelt ggf. noch SSH-Sitzungen zum Manager offen -- die erst
# Aufraeumer brav "AP-Pin geloest", und danach standen die Regeln trotzdem am Core: # beenden, bevor der Pin geloest wird.
# das Telefon waere nach jeder Runde gepinnt geblieben.
[[ -n "$KONSOLE" ]] && kill "$KONSOLE" 2>/dev/null && echo " Konsole beendet." [[ -n "$KONSOLE" ]] && kill "$KONSOLE" 2>/dev/null && echo " Konsole beendet."
# Muster bewusst ohne -e/-p: es gab beide Varianten, und die enge Fassung liess den # Muster bewusst ohne -e/-p: es gab beide Varianten, und die enge Fassung liess den
# Sampler stehen. # Sampler stehen.
pkill -f 'sshpass.*10.40.99.1' 2>/dev/null pkill -f 'sshpass.*10.40.99.1' 2>/dev/null
pkill -f 'tp6-monitor-core.sh' 2>/dev/null pkill -f 'tp6-monitor-core.sh' 2>/dev/null
sleep 1 sleep 1
bash docs/tp25-pin-s22.sh off >/dev/null 2>&1 && echo " AP-Pin geloest." bash docs/tp25-pin-multi.sh off >/dev/null 2>&1 && echo " Multi-Pin geloest."
echo echo
echo "Auswertung:" echo "Auswertung:"
echo " python3 docs/tp25-gehweg-auswertung.py --dir $TP6_RUN_DIR" echo " python3 docs/tp25-gehweg-auswertung.py --dir $TP6_RUN_DIR"

View File

@@ -44,6 +44,14 @@ HERE="$(cd "$(dirname "$0")/.." && pwd)"
mkdir -p "$RUN_DIR" mkdir -p "$RUN_DIR"
[[ -f "$MARKERS" ]] || echo "ts_epoch,ts_local,marker" > "$MARKERS" [[ -f "$MARKERS" ]] || echo "ts_epoch,ts_local,marker" > "$MARKERS"
# Client-Liste fuer die Klick-Konsole (docs/tp6-marker/server.py). Die Konsole liest sie
# ueber den .current-run-Zeiger -- so kennt sie die N Geraete auch, wenn sie als
# Dauer-Preview schon vor dem Messlauf lief. Format: name|ip|mac je Zeile.
: > "$RUN_DIR/geraete.txt"
for i in "${!PH_NAME[@]}"; do
printf '%s|%s|%s\n' "${PH_NAME[$i]}" "${PH_IP[$i]}" "${PH_MAC[$i]}" >> "$RUN_DIR/geraete.txt"
done
# Zeiger fuer die Klick-Konsole (docs/tp6-marker/server.py). Die laeuft als Dauer-Preview # 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 # 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 # Lauf von gestern, waehrend der Sampler hierher schreibt. Beides fiel erst bei der

View File

@@ -16,12 +16,12 @@ Zwei Aufgaben:
- Die Seite hat ein <meta refresh>. Ohne JS aktualisiert sich die Anleitung dadurch - 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. trotzdem; laeuft JS, entfernt es das Meta-Tag und pollt stattdessen leise.
🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der AP-Pin (TP25) schreibt 🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der Multi-Pin (TP25) schreibt
access-list-Regeln auf den WLAN-Manager. Die Seite bindet auf 0.0.0.0 und kennt keine 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 Authentifizierung — wer in VLAN10 steht, kann pinnen. Das ist fuer die Dauer einer Messung
einer Messung vertretbar und sonst nicht: den Server nach dem Rundgang beenden. Der Pin vertretbar und sonst nicht: den Server nach dem Rundgang beenden. Der Pin selbst ist
selbst ist harmlos genug (er betrifft eine MAC), aber die Regel ist es nicht, die als harmlos genug (er betrifft MACs), aber die Regel ist es nicht, die als naechstes hier
naechstes hier landen koennte. landen koennte.
Bindet auf 0.0.0.0 (Bedienung vom iPhone/iPad). Bindet auf 0.0.0.0 (Bedienung vom iPhone/iPad).
""" """
@@ -164,6 +164,82 @@ def sampler_tail():
return {} return {}
def geraete():
"""Liste (name, ip, mac) aus geraete.txt im Laufverzeichnis.
🔴 Die Datei schreibt tp25-walk-messung.sh beim Start des Messlaufs (eine Zeile
name|ip|mac je Client). Fehlt sie — Alt-Lauf oder Dauer-Preview, die schon vor dem
ersten Lauf lief — fallen wir auf die zwei bisherigen Geraete zurueck, damit die
Anzeige nicht leer ist. Die Namen muessen zu den sampler-<name>.csv /
walk-ping-<name>.csv passen, die die Harness schreibt.
"""
try:
zeilen = [z.strip() for z in open(_p("geraete.txt")) if z.strip()]
gs = []
for z in zeilen:
teile = z.split("|")
if len(teile) >= 3 and teile[0]:
gs.append((teile[0], teile[1], teile[2]))
if gs:
return gs
except Exception:
pass
return [("iphone", "", "F2:62:4C:E1:AF:09"), ("s22", "", "2A:0D:B0:5D:AD:A7")]
def client_tail(name):
"""Letzte Sampler-Zeile EINES Geraets (sampler-<name>.csv). Fehlt sie, ist das kein Fehler.
Gleiche Spalten wie sampler_tail() — die Harness schreibt pro Geraet einen eigenen
Sampler mit demselben Header (tp6-monitor-core.sh). Die Konsole braucht deshalb KEINE
eigene SSH-Sitzung mehr: sie liest nur, was die Sampler schon auf die Platte legen.
"""
try:
pfad = _p("sampler-%s.csv" % name)
rows = [r.strip() for r in open(pfad) 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 ping_loss(name, n=150):
"""Ping-Verlust der letzten ~n Zeilen von walk-ping-<name>.csv (5 Pings/s -> n=150 ~ 30 s).
Leeres ms-Feld = Verlust. Liefert (verloren, gesamt) oder None, wenn noch keine Daten da
sind. Der Header (ts_local,seq,ms) wird ueber die nicht-numerische seq-Spalte uebersprungen.
"""
try:
rows = [r.strip() for r in open(_p("walk-ping-%s.csv" % name)) if r.strip()]
verloren = gesamt = 0
for z in rows[-n:]:
teile = z.split(",")
if len(teile) >= 3 and teile[1].isdigit():
gesamt += 1
if not teile[2].strip():
verloren += 1
return (verloren, gesamt) if gesamt else None
except Exception:
return None
def wan_tail():
"""WAN/CPU-Anzeige: aus sampler.csv (Alt-Lauf) oder dem ersten Geraet-Sampler."""
w = sampler_tail()
if w.get("cpu") or w.get("wan_rx"):
return w
gs = geraete()
return client_tail(gs[0][0]) if gs else {}
def kill_flows(): def kill_flows():
"""Not-Aus: Saettigungs-Flows sofort beenden.""" """Not-Aus: Saettigungs-Flows sofort beenden."""
killed = 0 killed = 0
@@ -214,153 +290,104 @@ def ap_map():
return _apmap["karte"] return _apmap["karte"]
# 🔎 Die Messidee (Christoph, 2026-08-22): das iPhone roamt frei und zeigt damit, welchen AP # 🔎 Die Messidee (Christoph, 2026-08-22, generalisiert 2026-09-04): die Clients roamen frei
# ein normaler Client an dieser Stelle waehlt -- das wissen wir also ohnehin. Interessant # und zeigen damit, welchen AP ein normaler Client an dieser Stelle waehlt. Fuer die
# ist der ANDERE. Deshalb wird das S22 automatisch auf den Gegen-AP gezwungen: pro Standort # Abdeckungsfrage wird je Client per access-list GENAU EIN AP+Band erlaubt
# gibt es dann zwei Messpunkte statt einem, ohne zweimal zu gehen. # (tp25-pin-multi.sh). Die alte Gegen-AP-Automatik (S22 folgt dem iPhone) ist damit
IPHONE_MAC = os.environ.get("TP25_IPHONE_MAC", "F2:62:4C:E1:AF:09") # ueberholt: gepinnt wird vorab band-genau, nicht mehr automatisch waehrend des Gehens.
S22_MAC = os.environ.get("TP25_PIN_MAC", "2A:0D:B0:5D:AD:A7")
# Was der Beobachter sieht. "" = nicht angemeldet. PINM_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tp25-pin-multi.sh")
_live = {"iphone": "", "iphone_sig": "", "s22": "", "s22_sig": "", "stand": 0.0}
_auto = {"an": False, "seit_weg": 0.0, "aufgegeben": ""} # Pin-Zustand. Das Setzen dauert ~8 s (Regeln + Kick), deshalb laeuft es in einem Thread und
# die Seite zeigt solange "setze Pin …". pins = {name: "ap|band"} der zuletzt gesetzten Auswahl.
_pinm = {"busy": False, "msg": "", "pins": {}, "stand": 0.0}
def core_passwort(): def pin_targets():
"""Aus dem Credential-Store. Wird nie geloggt und nie in die Seite gerendert.""" """Eindeutige (ap, band)-Paare aus der Live-Karte, band-normalisiert fuer tp25-pin-multi.sh.
with open(os.path.expanduser("~/.secrets/credentials.env")) as fh:
for zeile in fh:
if zeile.startswith("CRS326_CORE_PASS="):
return zeile.split("=", 1)[1].strip().strip("\"'")
raise RuntimeError("CRS326_CORE_PASS nicht gefunden")
ap_map() liefert 'apX / 5 GHz' bzw. 'apX / 2,4 GHz'; das Skript erwartet Band '5' oder '24'.
def _ap_von(iface):
return ap_map().get(iface, "").split(" /")[0]
def beobachter():
"""Haelt EINE SSH-Sitzung offen und liest beide Telefone im 2-s-Takt.
🔴 Bewusst kein Poll mit je einem ssh-Aufruf: das waeren ueber einen Rundgang mehrere
hundert Verbindungsaufbauten, jeder ~1 s. Dasselbe Muster wie tp6-monitor-core.sh --
inklusive Reconnect-Schleife, weil die Sitzung am 2026-08-21 mitten in einer Messung
weggebrochen ist und die Daten still endeten.
""" """
# 🔴 KEINE Klammern um die :while-Schleife -- "(:while ...)" laesst RouterOS die ziele, gesehen = [], set()
# Sitzung wortlos beenden, und mit stderr auf DEVNULL sieht man nur, dass alle 3 s for wert in ap_map().values():
# ein neuer ssh startet. Genau so lag der Beobachter am 2026-08-22 tot. ap, _, band = wert.partition(" / ")
ros = (':while (true) do={' band = band.replace(" GHz", "").replace(",", ".")
':local a [/interface/wifi/registration-table find where mac-address="%s"];' if band.startswith("5"):
':local b [/interface/wifi/registration-table find where mac-address="%s"];' band = "5"
':local ai ""; :local as2 ""; :local bi ""; :local bs "";' elif band.startswith("2"):
':if ([:len $a] > 0) do={:set ai [/interface/wifi/registration-table get [:pick $a 0] interface];' band = "24"
':set as2 [/interface/wifi/registration-table get [:pick $a 0] signal]};' else:
':if ([:len $b] > 0) do={:set bi [/interface/wifi/registration-table get [:pick $b 0] interface];' continue
':set bs [/interface/wifi/registration-table get [:pick $b 0] signal]};' if (ap, band) not in gesehen:
':put ($ai . "," . $as2 . "," . $bi . "," . $bs); :delay 2s;}' gesehen.add((ap, band))
% (IPHONE_MAC, S22_MAC)) ziele.append((ap, band))
while True: return sorted(ziele)
try:
pw = core_passwort()
# 🔴 sshpass -e statt -p: bei -p steht das Router-Passwort in der KOMMANDOZEILE
# und damit in `ps`, in jedem Prozess-Trace und in jeder Debug-Ausgabe, die
# argv anfasst. Am 2026-08-22 ist es genau so im Klartext in einem Trace
# gelandet. Mit -e liegt es nur in der Umgebung dieses einen Kindprozesses.
umgebung = dict(os.environ, SSHPASS=pw)
proc = subprocess.Popen(
["sshpass", "-e", "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=15",
"admin@10.40.99.1", ros],
stdout=subprocess.PIPE, stderr=open(_p("beobachter.log"), "a"),
text=True, bufsize=1, env=umgebung)
# 🔴 NICHT `for zeile in proc.stdout`: die Iteration eines Pipes liest blockweise
# vor, die Zeilen kommen also erst an, wenn ~4 KB beisammen sind -- bei einer
# Zeile alle 2 s waere das eine Stunde. readline() liefert sofort.
for zeile in iter(proc.stdout.readline, ""):
teile = zeile.strip().split(",")
if len(teile) != 4:
continue
_live.update(iphone=teile[0], iphone_sig=teile[1],
s22=teile[2], s22_sig=teile[3], stand=time.time())
# 🔴 Bis 2026-08-22 blieben diese Zahlen NUR im Speicher, fuer die Anzeige.
# Nach dem Rundgang fehlte deshalb die halbe Messung: das S22 sitzt per
# Automatik immer auf dem Gegen-AP, die Alltagsabdeckung zeigt das iPhone
# -- und genau dessen Zeitreihe war weg. Nachtraeglich liess sich nur das
# Roaming-Log am Manager retten: Ereignisse, keine Kurve.
mitschreiben(teile)
entscheide()
except Exception as e:
# Ein Beobachter, der still stirbt, ist schlimmer als keiner (Lehre aus dem
# Sampler-Abbruch am 2026-08-21). Also mitschreiben statt schlucken.
try:
with open(_p("beobachter.log"), "a") as fh:
fh.write("%s %s: %s\n" % (time.strftime("%H:%M:%S"), type(e).__name__, e))
except Exception:
pass
time.sleep(3)
def mitschreiben(teile): def build_spec(form):
"""Beide Telefone im 2-s-Takt in den Lauf schreiben: iface + Signal je Geraet.""" """Aus den Formular-Auswahlen (pin_<name>=ap|band oder frei) den TP25_PINSPEC-String bauen.
try:
pfad = _p("beide-telefone.csv") Clients ohne Ziel (frei) bleiben frei — tp25-pin-multi.sh set raeumt vorher alle
frisch = not os.path.exists(pfad) or os.path.getsize(pfad) == 0 TP25-PINM-*-Regeln ab und setzt nur die hier genannten.
with open(pfad, "a") as fh: """
if frisch: eintraege = []
fh.write("ts_epoch,ts_local,iphone_iface,iphone_sig,s22_iface,s22_sig\n") for name, _ip, mac in geraete():
fh.write("%d,%s,%s\n" % (time.time(), time.strftime("%H:%M:%S"), ",".join(teile))) wert = (form.get("pin_%s" % name) or ["frei"])[0]
except Exception: if wert and wert != "frei" and "|" in wert:
pass ap, band = wert.split("|", 1)
eintraege.append("%s|%s|%s|%s" % (name, mac, ap, band))
return ";".join(eintraege)
def entscheide(): def pinm_aktion(spec):
"""Gegen-AP setzen, sobald das iPhone den AP wechselt.""" """Pin setzen (spec = 'name|mac|ap|band;...') oder loesen (spec = ''). Laeuft im Thread."""
if not _auto["an"] or _pin["busy"]: pins = {}
return for e in (spec.split(";") if spec else []):
ip_ap = _ap_von(_live["iphone"]) teile = e.split("|")
if ip_ap not in ("ap1", "ap2"): if len(teile) >= 4:
return # iPhone selbst nicht angemeldet -> nichts zu spiegeln pins[teile[0]] = "%s|%s" % (teile[2], teile[3])
gegen = "ap1" if ip_ap == "ap2" else "ap2"
if pin_lesen() != gegen:
_auto["aufgegeben"] = ""
_auto["seit_weg"] = time.time()
pin_setzen(gegen)
# 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(): def lauf():
try: try:
r = subprocess.run(["bash", PIN_SH, ziel], capture_output=True, text=True, timeout=60) umgebung = dict(os.environ)
_pin["msg"] = (r.stdout.strip().splitlines() or [""])[0] if spec:
umgebung["TP25_PINSPEC"] = spec
r = subprocess.run(["bash", PINM_SH, "set"], capture_output=True, text=True,
timeout=90, env=umgebung)
else:
r = subprocess.run(["bash", PINM_SH, "off"], capture_output=True, text=True,
timeout=90)
_pinm["msg"] = r.stdout.strip() or r.stderr.strip() or "ok"
except Exception as e: except Exception as e:
_pin["msg"] = "Fehler: %s" % e _pinm["msg"] = "Fehler: %s" % e
finally: finally:
_pin["busy"] = False _pinm["busy"] = False
_pin["stand"] = 0.0 _pinm["stand"] = time.time()
pin_lesen()
_pin["busy"] = True _pinm["busy"] = True
_pin["msg"] = "schaltet um …" _pinm["msg"] = "setze Pin …" if spec else "löse Pin …"
_pinm["pins"] = pins
# Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher # Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher
# nicht sagen, welche Messpunkte zu welchem AP gehoeren. # nicht sagen, welche Messpunkte zu welchem Pin gehoeren.
write_marker("PIN=%s" % ziel) write_marker("PINM=%s" % (spec or "off"))
threading.Thread(target=lauf, daemon=True).start()
def pinm_verify():
"""tp25-pin-multi.sh verify im Thread — zeigt Regeln + aktuelle Anmeldungen."""
def lauf():
try:
r = subprocess.run(["bash", PINM_SH, "verify"], capture_output=True, text=True,
timeout=60)
_pinm["msg"] = r.stdout.strip() or r.stderr.strip() or "ok"
except Exception as e:
_pinm["msg"] = "Fehler: %s" % e
finally:
_pinm["busy"] = False
_pinm["stand"] = time.time()
_pinm["busy"] = True
_pinm["msg"] = "prüfe …"
threading.Thread(target=lauf, daemon=True).start() threading.Thread(target=lauf, daemon=True).start()
@@ -391,27 +418,42 @@ def fmt_bps(v):
def render(st): def render(st):
w = sampler_tail() w = wan_tail()
rest = max(0, int(st.get("ends", 0) - time.time())) if st.get("ends") else 0 rest = max(0, int(st.get("ends", 0) - time.time())) if st.get("ends") else 0
mode = st.get("mode", "wartet") mode = st.get("mode", "wartet")
banner = "" banner = ""
alarm = False # nur DIESER Zustand loest Ton + Vibration aus, nicht jeder Banner 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 haengende = []
# ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von einem Funkloch zu
# unterscheiden -- und der Manager protokolliert die Abweisung auf info-Ebene nicht. # 🔴 Der wichtigste Alarm der Seite, seit es den Pin gibt (2026-08-22, generalisiert
# Android quittiert sie mit "Verbindung fehlgeschlagen" und WAEHLT DAS NETZ AB, danach # 2026-09-04): ein vom Pin ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht
# misst der restliche Rundgang nur noch Stille. Deshalb wird "haengt nirgends" hier # von einem Funkloch zu unterscheiden -- und der Manager protokolliert die Abweisung auf
# nicht klein in der WLAN-Zeile gezeigt, sondern schreit. # info-Ebene nicht. Android quittiert sie mit "Verbindung fehlgeschlagen" und WAEHLT DAS
if pin_lesen() in ("ap1", "ap2") and w.get("iface") == "absent" and w.get("age", 999) < 30: # NETZ AB, danach misst der restliche Rundgang nur noch Stille. Deshalb wird "haengt
alarm = True # nirgends" hier nicht klein in der Client-Zeile gezeigt, sondern schreit.
banner = ('<div class="banner warn">📵 S22 HÄNGT NIRGENDS — Display an und wieder auf ' clients = []
'dirtyvader-main verbinden. Der Pin wirft es vom gesperrten AP; Android ' for name, ip, mac in geraete():
'wählt das Netz dann von selbst ab. <b>Nicht weitergehen</b>, sonst misst ' t = client_tail(name)
'du Stille statt Abdeckung.</div>') pl = ping_loss(name)
iface = t.get("iface", "")
ap = ap_map().get(iface, iface) if iface else ""
signal = t.get("signal", "")
frisch = t.get("age", 999) < 30
gepinnt = name in _pinm["pins"]
if gepinnt and iface == "absent" and frisch:
alarm = True
haengende.append(name)
clients.append((name, ip, mac, iface, ap, signal, pl, frisch, gepinnt))
if haengende:
banner = ('<div class="banner warn">📵 <b>%s</b> HÄNGT NIRGENDS — der Pin wirft das '
'Geraet vom gesperrten AP; Android waehlt das Netz dann von selbst ab. '
'<b>Nicht weitergehen</b>, sonst misst du Stille statt Abdeckung.</div>'
% ", ".join(haengende))
elif not st.get("driver"): elif not st.get("driver"):
banner = ('<div class="banner done">ℹ️ Keine Ping-Aufzeichnung — Räume markieren und ' banner = ('<div class="banner done">ℹ️ Keine Ping-Aufzeichnung — Räume markieren und '
'AP-Pin gehen trotzdem. Für die Messkurve <b>tp25-walk-messung.sh</b> starten.</div>') 'pinnen geht trotzdem. Für die Messkurve <b>tp25-walk-messung.sh</b> starten.</div>')
elif mode == "langzeit": elif mode == "langzeit":
banner = ('<div class="banner done">✅ MESSUNG ABGESCHLOSSEN — die Zahlen liegen vor. ' banner = ('<div class="banner done">✅ MESSUNG ABGESCHLOSSEN — die Zahlen liegen vor. '
'Ab jetzt läuft nur noch die Langzeit-Aufzeichnung.</div>') 'Ab jetzt läuft nur noch die Langzeit-Aufzeichnung.</div>')
@@ -423,86 +465,65 @@ def render(st):
countdown = ('<div class="cd" id="cd" data-ends="%d">noch ~%d s</div>' % (st.get("ends", 0), rest) 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>') if rest else '<div class="cd" id="cd" data-ends="0"></div>')
wlan = "—" # Client-Karten: je Geraet Name, AP+Signal, Ping-Verlust der letzten ~30 s.
if w.get("iface"): karten = ""
wlan = "%s · %s dBm · tx %s / rx %s" % ( for name, ip, mac, iface, ap, signal, pl, frisch, gepinnt in clients:
ap_map().get(w["iface"], w["iface"]), w.get("signal", "?"), if iface and iface != "absent":
fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps"))) status = ("%s · %s dBm" % (ap, signal)) if signal else ap
elif iface == "absent":
status = "<b>nirgends</b>"
else:
status = "—"
if pl:
verloren, gesamt = pl
ping = "%d/%d verloren" % (verloren, gesamt)
else:
ping = "—"
karten += ('<div class="card"><div class="phase">%s</div>'
'<div class="inst"><b>%s</b></div>'
'<div class="cval">%s</div>'
'<div class="cval">Ping: %s</div></div>'
% ("📌 gepinnt" if gepinnt else "Client", name, status, ping))
ziel = pin_lesen() # Pin-UI: je Client ein Ziel-Dropdown, dazu setzen/loesen/verify.
ip_ap, s22_ap = _ap_von(_live["iphone"]), _ap_von(_live["s22"]) ziele = pin_targets()
frisch = time.time() - _live["stand"] < 15
# Drei Zustaende, nicht zwei. "S22 kommt nicht rein" ist ein ERGEBNIS (der Gegen-AP def optionen(aktuell):
# traegt hier nicht), kein Defekt -- es darf deshalb nicht wie ein Fehler aussehen und out = ['<option value="frei"%s>frei</option>' % (" selected" if aktuell == "frei" else "")]
# nicht ewig Alarm schlagen. for ap, band in ziele:
# 🔴 "beide angemeldet" ist NICHT das Kriterium -- der Messpunkt taugt nur, wenn die wert = "%s|%s" % (ap, band)
# beiden Telefone auf VERSCHIEDENEN APs sitzen. Am 2026-08-22 meldete die Seite label = "%s / %s GHz" % (ap, "5" if band == "5" else "2,4")
# "✅ beide angemeldet, auf verschiedenen APs", waehrend beide auf ap2 hingen: ein out.append('<option value="%s"%s>%s</option>'
# gruenes Licht fuer eine Messung, die es gar nicht gab. Wieder derselbe Fehler -- % (wert, " selected" if wert == aktuell else "", label))
# die Anzeige geprueft statt der Wirkung. return "".join(out)
getrennt = bool(ip_ap and s22_ap and ip_ap != s22_ap)
bereit = bool(frisch and getrennt)
gleich = bool(frisch and ip_ap and s22_ap and ip_ap == s22_ap)
wartet = bool(frisch and ip_ap and not s22_ap and not _auto["aufgegeben"])
if wartet and _auto["seit_weg"] and time.time() - _auto["seit_weg"] > 45:
_auto["aufgegeben"] = ziel or "?"
write_marker("UNERREICHBAR=%s" % (ziel or "?"))
wartet = False
def z(ap, sig, mac_name): zeilen = ""
if not frisch: for name, ip, mac in geraete():
return "%s: —" % mac_name zeilen += ('<div class="pinzeile"><label>%s</label>'
return ("%s: <b>%s</b> %s dBm" % (mac_name, ap, sig)) if ap else ("%s: <b>nirgends</b>" % mac_name) '<select name="pin_%s">%s</select></div>'
% (name, name, optionen(_pinm["pins"].get(name, "frei"))))
doppel = "%s &nbsp;·&nbsp; %s" % (z(ip_ap, _live["iphone_sig"], "iPhone"), if _pinm["busy"]:
z(s22_ap, _live["s22_sig"], "S22")) pinstatus = "⏳ %s" % _pinm["msg"].replace("\n", "<br>")
elif _pinm["msg"]:
if _pin["busy"]: pinstatus = _pinm["msg"].replace("\n", "<br>")
pinstatus = "⏳ %s<br>%s" % (_pin["msg"], doppel) elif not ziele:
elif _auto["aufgegeben"]: pinstatus = "Manager nicht erreichbar — keine Pin-Ziele."
pinstatus = ("🚫 S22 bekommt <b>%s</b> hier nicht — <b>das ist das Messergebnis</b>, "
"Raum markieren und weitergehen.<br>%s" % (_auto["aufgegeben"], doppel))
elif bereit:
pinstatus = "✅ beide angemeldet, <b>verschiedene APs</b> — Messpunkt zählt<br>%s" % doppel
elif gleich and _auto["an"]:
pinstatus = ("⏳ beide auf <b>%s</b> — Gegen-AP wird gerade nachgezogen, "
"noch nicht weitergehen<br>%s" % (ip_ap, doppel))
elif gleich:
pinstatus = ("⚠️ beide auf <b>%s</b> — <b>kein Gegen-AP-Messpunkt.</b> "
"Automatik einschalten oder S22 von Hand pinnen.<br>%s" % (ip_ap, doppel))
elif wartet:
pinstatus = "⏳ S22 sucht <b>%s</b> …<br>%s" % (ziel or "?", doppel)
elif ziel in ("ap1", "ap2"):
pinstatus = "S22 gepinnt auf <b>%s</b><br>%s" % (ziel, doppel)
elif ziel == "off":
pinstatus = "kein Pin — S22 roamt frei<br>%s" % doppel
else: else:
pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)" pinstatus = "Je Client AP/Band wählen, dann „Pin setzen“."
pinkarte = (
'<div class="card"><span id="sig" data-alarm="%d" data-busy="%d" data-pin="%s" ' pinm_karte = (
'data-bereit="%d"></span>' '<div class="card"><span id="sig" data-alarm="%d" data-busy="%d"></span>'
'<div class="phase">AP-Pin S22</div>' '<div class="phase">Pinnen (Multi-Pin)</div>'
'<div class="inst" id="pininst">%s</div><div class="raster">' '<div class="inst" id="pininst">%s</div>'
'<form method="post" action="/pin?ap=ap1" onsubmit="return sendpin(event,\'ap1\')">' '<form method="post" action="/pinmulti" onsubmit="return sendpinm(event)">'
'<button class="raum%s" type="submit">ap1</button></form>' '%s'
'<form method="post" action="/pin?ap=ap2" onsubmit="return sendpin(event,\'ap2\')">' '<button class="ok" type="submit">📌 Pin setzen</button></form>'
'<button class="raum%s" type="submit">ap2</button></form>' '<form method="post" action="/pinmulti?off=1" onsubmit="return sendpinmoff(event)">'
'</div>' '<button class="cam" type="submit">Pin lösen</button></form>'
'<form method="post" action="/auto" onsubmit="return sendauto(event)">' '<form method="post" action="/pinmulti?verify=1" onsubmit="return sendpinmoff(event)">'
'<button class="%s" style="margin-top:8px" type="submit">%s</button></form>' '<button class="cam" type="submit">verify</button></form>'
'<form method="post" action="/pin?ap=off" onsubmit="return sendpin(event,\'off\')">' '</div>' % (1 if alarm else 0, 1 if _pinm["busy"] else 0, pinstatus, zeilen))
'<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 "?",
1 if bereit else 0,
pinstatus,
" hier" if ziel == "ap1" else "",
" hier" if ziel == "ap2" else "",
"ok" if _auto["an"] else "cam",
"🔁 Gegen-AP: AN (folgt dem iPhone)" if _auto["an"]
else "🔁 Gegen-AP automatisch",
)
)
raum, seit = current_room() raum, seit = current_room()
# 🔴 Die Kacheln hingen bis 2026-08-22 am Treiber-Zustand -- ohne laufenden TP6-Treiber # 🔴 Die Kacheln hingen bis 2026-08-22 am Treiber-Zustand -- ohne laufenden TP6-Treiber
@@ -547,8 +568,9 @@ def render(st):
return PAGE.format( return PAGE.format(
banner=banner, title=st.get("title", "—"), phase=st.get("phase", "—"), banner=banner, title=st.get("title", "—"), phase=st.get("phase", "—"),
instruction=st.get("instruction", ""), prog=prog, countdown=countdown, instruction=st.get("instruction", ""), prog=prog, countdown=countdown,
raumzeile=raumzeile, pinkarte=pinkarte, wlan=wlan, wan="rx %s / tx %s · CPU %s%%" % (fmt_bps(w.get("wan_rx")), raumzeile=raumzeile, clients=karten, pinm_karte=pinm_karte,
fmt_bps(w.get("wan_tx")), w.get("cpu", "?")), 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, notaus=notaus, last=last_marker(), buttons=btns, notaus=notaus,
) )
@@ -579,6 +601,11 @@ button:active{{filter:brightness(1.3)}}
.raster form{{margin:0}} .raster form{{margin:0}}
.raum{{margin:0;padding:16px 8px;font-size:16px;background:#1e3a5f}} .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}} .raum.hier{{background:#0ea5e9;color:#06202e;box-shadow:0 0 0 3px #7dd3fc inset}}
.cval{{font:14px/1.6 ui-monospace,Menlo,monospace;color:#9aa4b2;margin-top:2px}}
.pinzeile{{display:flex;align-items:center;gap:10px;margin:8px 0}}
.pinzeile label{{flex:0 0 90px;font-weight:600}}
.pinzeile select{{flex:1;padding:10px;font-size:16px;background:#262b34;color:#e8eaed;
border:1px solid #2a2f3a;border-radius:8px}}
form{{margin:0}} form{{margin:0}}
</style> </style>
{banner} {banner}
@@ -589,10 +616,10 @@ form{{margin:0}}
{countdown} {countdown}
{prog} {prog}
</div> </div>
{pinkarte} {clients}
{pinm_karte}
{raumzeile} {raumzeile}
<div class="live">WLAN {wlan} <div class="live">WAN {wan}
WAN {wan}
Marker {last}</div> Marker {last}</div>
{buttons} {buttons}
{notaus} {notaus}
@@ -638,18 +665,10 @@ function alarmton(){{ ton(880,180,0); ton(660,180,0.24); ton(880,260,0.48);
function okton(){{ ton(1320,90,0); ton(1760,120,0.11); function okton(){{ ton(1320,90,0); ton(1760,120,0.11);
try{{ navigator.vibrate && navigator.vibrate(90); }}catch(e){{}} }} try{{ navigator.vibrate && navigator.vibrate(90); }}catch(e){{}} }}
function bereitton(){{ ton(1180,110,0); ton(1580,150,0.13); var letzterAlarm = 0, letzterBusy = 0, alarmTimer = null;
try{{ navigator.vibrate && navigator.vibrate([60,60,60]); }}catch(e){{}} }}
var letzterAlarm = 0, letzterBusy = 0, letzterBereit = 0, alarmTimer = null;
function signale(){{ function signale(){{
var sig = document.getElementById('sig'); if(!sig) return; var sig = document.getElementById('sig'); if(!sig) return;
var alarm = sig.dataset.alarm === '1', busy = sig.dataset.busy === '1'; var alarm = sig.dataset.alarm === '1', busy = sig.dataset.busy === '1';
var bereit = sig.dataset.bereit === '1';
// Der Piep, auf den Christoph wartet: beide Telefone haengen, auf verschiedenen APs --
// der Messpunkt ist vollstaendig, weitergehen. Nur bei ZUSTANDSWECHSEL, nicht dauernd.
if(bereit && !letzterBereit) bereitton();
letzterBereit = bereit ? 1 : 0;
if(alarm && !letzterAlarm){{ if(alarm && !letzterAlarm){{
alarmton(); alarmton();
// Solange nichts geschieht, alle 12 s erinnern -- ein einmaliger Piep in der Hosentasche // Solange nichts geschieht, alle 12 s erinnern -- ein einmaliger Piep in der Hosentasche
@@ -657,7 +676,7 @@ function signale(){{
if(!alarmTimer) alarmTimer = setInterval(alarmton, 12000); if(!alarmTimer) alarmTimer = setInterval(alarmton, 12000);
}} }}
if(!alarm && alarmTimer){{ clearInterval(alarmTimer); alarmTimer = null; }} if(!alarm && alarmTimer){{ clearInterval(alarmTimer); alarmTimer = null; }}
if(!busy && letzterBusy) okton(); // Umschalten fertig if(!busy && letzterBusy) okton(); // Pin-Aktion fertig
letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0; letzterAlarm = alarm ? 1 : 0; letzterBusy = busy ? 1 : 0;
}} }}
@@ -672,28 +691,22 @@ function sendstop(ev){{
return false; return false;
}}catch(e){{ return true; }} }}catch(e){{ return true; }}
}} }}
function sendauto(ev){{ function sendpinm(ev){{
try{{ try{{
ev.preventDefault(); unlock(); okton(); ev.preventDefault(); unlock(); okton();
fetch('/auto', {{method:'POST'}}).catch(function(){{}}); var inst = document.getElementById('pininst');
setTimeout(refresh, 400); setTimeout(refresh, 3000); if(inst) inst.innerHTML = '⏳ setze Pin …<br>Gepinnte Geräte fliegen kurz aus dem WLAN und verbinden sich neu.';
var fd = new FormData(ev.target);
fetch('/pinmulti', {{method:'POST', body: new URLSearchParams(fd)}}).catch(function(){{}});
setTimeout(refresh, 3000); setTimeout(refresh, 9000); setTimeout(refresh, 20000);
return false; return false;
}}catch(e){{ return true; }} }}catch(e){{ return true; }}
}} }}
function sendpin(ev, ap){{ function sendpinmoff(ev){{
try{{ try{{
ev.preventDefault(); unlock(); ev.preventDefault(); unlock(); okton();
// 🔴 Der Klick kommt vom S22 SELBST -- also von genau dem Geraet, das der Pin gleich vom fetch(ev.target.action, {{method:'POST'}}).catch(function(){{}});
// WLAN wirft. Die Antwort auf dieses fetch() kommt deshalb womoeglich nie an, und die setTimeout(refresh, 3000); setTimeout(refresh, 9000);
// 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; return false;
}}catch(e){{ return true; }} }}catch(e){{ return true; }}
}} }}
@@ -767,37 +780,27 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, json.dumps({"ok": True})) if "text/html" not in ( self._send(200, json.dumps({"ok": True})) if "text/html" not in (
self.headers.get("Accept") or "") else self._back() self.headers.get("Accept") or "") else self._back()
return return
if self.path.startswith("/auto"): if self.path.startswith("/pinmulti"):
# Ohne Parameter umschalten (das macht der Knopf), mit ?an=0|1 gezielt setzen -- if _pinm["busy"]:
# tp25-runde.sh schaltet damit beim Start scharf. Ein Toggle waere dort falsch: if "text/html" in (self.headers.get("Accept") or ""):
# laeuft die Konsole schon mit Automatik, haette der Start sie AUSgeschaltet. self._back()
if "an=" in self.path: else:
_auto["an"] = self.path.split("an=", 1)[1].split("&")[0] in ("1", "true", "an") self._send(409, json.dumps({"error": "laeuft schon"}))
return
qs = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if qs.get("off"):
pinm_aktion("")
elif qs.get("verify"):
pinm_verify()
else: else:
_auto["an"] = not _auto["an"] laenge = int(self.headers.get("Content-Length") or 0)
_auto["aufgegeben"] = "" body = self.rfile.read(laenge).decode("utf-8") if laenge else ""
_auto["seit_weg"] = time.time() form = urllib.parse.parse_qs(body)
write_marker("AUTO=%s" % ("an" if _auto["an"] else "aus")) pinm_aktion(build_spec(form))
if _auto["an"]:
entscheide()
if "text/html" in (self.headers.get("Accept") or ""): if "text/html" in (self.headers.get("Accept") or ""):
self._back() self._back()
else: else:
self._send(200, json.dumps({"ok": True, "auto": _auto["an"]})) self._send(200, json.dumps({"ok": True}))
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 return
if self.path.startswith("/m?"): if self.path.startswith("/m?"):
# 🔴 Erst dekodieren, dann NICHT hochschreiben: "RAUM%3DDiele%20OG" wurde sonst zu # 🔴 Erst dekodieren, dann NICHT hochschreiben: "RAUM%3DDiele%20OG" wurde sonst zu
@@ -836,8 +839,7 @@ if __name__ == "__main__":
print("Marker -> %s" % markers_pfad()) print("Marker -> %s" % markers_pfad())
for iface, wo in sorted(ap_map().items()): for iface, wo in sorted(ap_map().items()):
print(" %-14s %s" % (iface, wo)) print(" %-14s %s" % (iface, wo))
threading.Thread(target=beobachter, daemon=True).start() # 🔴 Threading ist Pflicht, seit der Pin dazugekommen ist: das Setzen redet ueber SSH
# 🔴 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 # mit dem Manager und braucht ~8 s. Mit dem alten HTTPServer (single-threaded) haette
# das die ganze Konsole blockiert -- inklusive Not-Aus. # das die ganze Konsole blockiert -- inklusive Not-Aus.
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()