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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
120
docs/tp25-pin-s22.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user