diff --git a/.gitignore b/.gitignore index aca543d..e444e77 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ docs/nas-vlan20/inspect_*.json routeros/state-backups/ routeros/ap1-wg-hub.privkey routeros/tp15-core-cutover-intended.rsc +docs/tp6-marker/.current-run diff --git a/docs/teilprojekte.md b/docs/teilprojekte.md index e71147b..b231d14 100644 --- a/docs/teilprojekte.md +++ b/docs/teilprojekte.md @@ -67,9 +67,10 @@ ab jetzt bei jeder Änderung mitgezogen, nicht nur die TP-Abschnitte.)* **Nächster Schritt (Stand 2026-08-21 abends): TP25 — Steering-Test in den Schlafräumen.** > 📋 **Einstieg: [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md)** — dort steht > der verifizierte Ist-Stand, die Messfallen und der nächste Test. -> ⚠️ **Auf `crs326-core` ist eine Config-Transaktion OFFEN** (`TEST-ether3-trunk-ap1-sideboard`, -> Frist 2026-09-04): ap1 hängt testweise am Sideboard, `ether3` wurde dafür zum Trunk. Die Compliance -> ist deshalb **absichtlich** nicht 92/92 — `python3 nautobot/gc-transaktion.py status`. +> ✅ **`crs326-core` ist transaktionsfrei (2026-08-22).** `TEST-ether3-trunk-ap1-sideboard` und +> `TEST-infra-ssid-matter-pairing` sind beide zurückgerollt und geschlossen; ap1 hängt wieder auf +> `ether15`, `ether3` ist wieder untagged VLAN40. Ein gemeldeter Drift ist ab jetzt **echt**, nicht +> beabsichtigt — `python3 nautobot/gc-transaktion.py status`. > 🆕 **TP29** (Anwesenheitserkennung fürs Energiesparen) ist als Idee angelegt, nicht auf dem Pfad. > 📌 **Die TP6-Prämisse ist am 2026-08-20/21 gemessen** (Runbook §5): formal Fall B (+28…+47 ms unter > Dauer-Upload), aber im Videocall **nicht wahrnehmbar** — kein einziger Problem-Klick über sechs @@ -1364,8 +1365,8 @@ FT kaschierte das bisher als „Roaming" statt „schwache Abdeckung". > ⇒ **Fund E** (`signal-range`-Boden am Manager) bleibt unverändert die richtige Maßnahme und ist nach > dem Rückbau der **oberste offene Punkt** in TP25 — nicht erledigt, nicht gegenstandslos. > 📋 **Übergabe: [`handoff-tp25-session-20260821.md`](handoff-tp25-session-20260821.md).** -> ⚠️ **Auf `crs326-core` ist eine Config-Transaktion OFFEN** (`TEST-ether3-trunk-ap1-sideboard`, Scope -> `bridge-vlan`, Frist 2026-09-04) — die Compliance ist deshalb **absichtlich** nicht 92/92. +> ✅ **Transaktion `TEST-ether3-trunk-ap1-sideboard` am 2026-08-22 zurückgerollt und geschlossen** +> (Scope `bridge-vlan`) — `ether3` ist wieder untagged VLAN40, Compliance zurück auf 92/92. > **Und das passiert TROTZ aktivem 802.11k/v und FT** — live geprüft: `st-main` mit `rrm=yes`/`wnm=yes`, > `ft-over-ds=yes`. Die Merkregel `ref_ft_is_not_roaming` („11k/v fehlen") ist damit **historisch**. > **Reihenfolge der Maßnahmen** (Doku §6, respektiert die User-Randbedingung „kein neuer AP, solange diff --git a/docs/tp25-pin-s22.sh b/docs/tp25-pin-s22.sh new file mode 100755 index 0000000..6c96f9a --- /dev/null +++ b/docs/tp25-pin-s22.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# TP25 — einen Client per access-list auf EINEN AP festnageln. +# +# Wozu: die Gehweg-Messungen von 08-20/21 zeigen den Sticky-Client (Fund B) und das +# Band-Ping-Pong (Fund C) — aber sie zeigen NICHT, wie gut ein einzelner AP eine Wohnung +# allein ausleuchtet, weil der Client waehrend des Wegs wechselt. Wer die Abdeckung von +# ap1 und ap2 vergleichen will, muss den Wechsel vorher ausschliessen. +# +# Mechanik: am MANAGER (crs326-core) wird der Client auf den Radios des jeweils ANDEREN +# AP mit action=reject abgewiesen. Uebrig bleibt genau ein AP. Die Baender dieses AP +# bleiben bewusst BEIDE offen — gepinnt wird der Standort, nicht das Band. +# +# bash docs/tp25-pin-s22.sh ap2 # nur ap2 erlaubt (ap1 abgewiesen) +# bash docs/tp25-pin-s22.sh ap1 # nur ap1 erlaubt (ap2 abgewiesen) +# bash docs/tp25-pin-s22.sh off # Pin loesen, Normalbetrieb +# bash docs/tp25-pin-s22.sh status # Regeln + aktuelle Anmeldung zeigen +# bash docs/tp25-pin-s22.sh map # cap-wifiN -> apX/Band, LIVE vom Manager +# bash docs/tp25-pin-s22.sh which # auf welchem AP der Pin gerade steht (ap1|ap2|off) +# +# 🔴 Die access-list steht unter KEINER ComplianceRule (Stand 2026-08-22: es gibt +# wifi-configuration/-datapath/-security/-steering/-provisioning/-channel/-cap/-capsman, +# aber keine fuer access-list). Dieser Change erzeugt deshalb KEINEN Drift und wird von +# Nautobot auch nicht bemerkt. Genau darum traegt jede Regel den Kommentar TP25-PIN-S22 +# und das Skript raeumt vor jedem Setzen auf: es gibt keine zweite Instanz, die das tut. +# Wenn Fund E (signal-range-Boden) dauerhaft kommt, gehoert diese Luecke geschlossen. +# +# ⚠️ Android randomisiert die MAC je SSID. Die hier gepinnte Adresse gilt fuer +# dirtyvader-main; wechselt das Telefon die SSID oder wuerfelt neu, greift der Pin +# still nicht mehr. Kontrolle ist immer `status`, nie die Annahme. + +set -uo pipefail + +MAC="${TP25_PIN_MAC:-2A:0D:B0:5D:AD:A7}" +TAG="TP25-PIN-S22" +ROUTER_IP="${TP25_MANAGER_IP:-10.40.99.1}" + +# 🔴 Die cap-wifiN-Nummern werden vom Manager DYNAMISCH vergeben, in der Reihenfolge, in der +# sich die CAPs anmelden. Ein fest verdrahtetes Mapping ist damit nur so lange richtig, bis +# ein AP einmal neu bootet oder umgesteckt wird -- und es faellt still falsch aus, weil die +# Zahlen weiter existieren. (Genau so standen sie am 2026-08-22 in docs/tp6-marker/server.py +# und docs/tp25-gehweg-auswertung.py verkehrt herum: Baender richtig, ap1/ap2 vertauscht.) +# Deshalb wird hier NICHTS gecacht -- die Zuordnung kommt bei jedem Aufruf vom Geraet. + +ZIEL="${1:-status}" + +PW="$(grep -m1 '^CRS326_CORE_PASS=' ~/.secrets/credentials.env | cut -d= -f2- | sed 's/^["'"'"']//; s/["'"'"']$//')" +[[ -n "$PW" ]] || { echo "CRS326_CORE_PASS nicht in ~/.secrets/credentials.env gefunden" >&2; exit 1; } + +ros() { + sshpass -p "$PW" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 \ + "admin@${ROUTER_IP}" "$1" 2>&1 | sed "s/${PW//\//\\/}/***/g" +} + +# cap-wifiN -> "apX Band" -- live, ohne Cache. Ausgabe je Zeile: cap-wifi3=ap1/5GHz +karte() { + ros ':foreach i in=[/interface wifi find where !master-interface] do={:local c [/interface wifi get $i cap]; :local f [/interface wifi get $i channel.frequency]; :local b "2,4GHz"; :if ($f > 3000) do={:set b "5GHz"}; :put ([/interface wifi get $i name] . "=" . [:pick $c 0 [:find $c "@"]] . "/" . $b)}' \ + | tr -d '\r' | grep '^cap-wifi[0-9]*=' +} + +# Auf welchem AP steht der Pin? Ergibt sich aus den Regeln: abgewiesen wird der ANDERE AP. +welcher() { + local m; m="$(karte)" + local regeln; regeln="$(ros "/interface wifi access-list print terse where comment=\"$TAG\"")" + [[ -z "${regeln//[[:space:]]/}" ]] && { echo off; return; } + local blockiert; blockiert="$(echo "$regeln" | grep -o 'interface=cap-wifi[0-9]*' | cut -d= -f2)" + local aps_blockiert; aps_blockiert="$(for i in $blockiert; do echo "$m" | grep "^$i=" | cut -d= -f2 | cut -d/ -f1; done | sort -u)" + # Uebrig bleibt der AP, der NICHT blockiert ist. + for ap in $(echo "$m" | cut -d= -f2 | cut -d/ -f1 | sort -u); do + echo "$aps_blockiert" | grep -qx "$ap" || { echo "$ap"; return; } + done + echo off +} + +# Die Interfaces EINES AP -- live ermittelt, nicht geraten. +ifaces_von() { + karte | grep "=$1/" | cut -d= -f1 +} + +aufraeumen() { + ros "/interface wifi access-list remove [find where comment=\"$TAG\"]" >/dev/null +} + +# Der Reject greift erst bei der NAECHSTEN Anmeldung. Ohne Kick bleibt das Telefon +# u.U. minutenlang auf dem verbotenen AP haengen -- und der Rundgang misst dann nichts. +kick() { + ros "/interface wifi registration-table remove [find where mac-address=\"$MAC\"]" >/dev/null +} + +zeigen() { + echo "--- Regeln ($TAG) ---" + local r; r="$(ros "/interface wifi access-list print terse where comment=\"$TAG\"")" + if [[ -z "${r//[[:space:]]/}" ]]; then echo " (keine -- Pin ist AUS)"; else echo "$r" | sed 's/^/ /'; fi + echo "--- Anmeldung $MAC ---" + local a; a="$(ros "/interface wifi registration-table print terse where mac-address=\"$MAC\"")" + if [[ -z "${a//[[:space:]]/}" ]]; then echo " (nicht angemeldet)"; else echo "$a" | sed 's/^/ /'; fi +} + +case "$ZIEL" in + ap1|ap2) + ANDERER="ap1"; [[ "$ZIEL" == "ap1" ]] && ANDERER="ap2" + SPERREN="$(ifaces_von "$ANDERER" | tr '\n' ' ')" + if [[ -z "${SPERREN//[[:space:]]/}" ]]; then + echo "Keine Interfaces fuer $ANDERER gefunden -- haengt der AP am Manager? Abbruch." >&2; exit 1 + fi + aufraeumen + for i in $SPERREN; do + ros "/interface wifi access-list add mac-address=$MAC interface=$i action=reject comment=\"$TAG\"" >/dev/null + done + echo "S22 -> nur $ZIEL ($ANDERER abgewiesen auf: $SPERREN)"; kick ;; + off) + aufraeumen; echo "Pin geloest -- S22 darf wieder ueberall."; kick ;; + status) ;; + map) karte; exit 0 ;; + which) welcher; exit 0 ;; + *) echo "Aufruf: $0 {ap1|ap2|off|status|map|which}" >&2; exit 2 ;; +esac + +[[ "$ZIEL" == "status" ]] || sleep 6 +zeigen diff --git a/docs/tp25-walk-messung.sh b/docs/tp25-walk-messung.sh index 1fe3824..e344dd6 100755 --- a/docs/tp25-walk-messung.sh +++ b/docs/tp25-walk-messung.sh @@ -28,6 +28,12 @@ HERE="$(cd "$(dirname "$0")/.." && pwd)" mkdir -p "$RUN_DIR" [[ -f "$MARKERS" ]] || echo "ts_epoch,ts_local,marker" > "$MARKERS" +# Zeiger fuer die Klick-Konsole (docs/tp6-marker/server.py). Die laeuft als Dauer-Preview +# auf Port 3031 und kennt dieses Verzeichnis sonst nicht -- ihre Marker landeten dann im +# Lauf von gestern, waehrend der Sampler hierher schreibt. Beides fiel erst bei der +# Auswertung auf, wenn die Raummarken fehlten. +printf '%s\n' "$RUN_DIR" > "$HERE/docs/tp6-marker/.current-run" + # Frueheren Lauf beiseitelegen, statt ihn zu ueberschreiben. if [[ -f "$RUN_DIR/sampler.csv" ]]; then ARCHIV="$RUN_DIR/archiv-$(date +%H%M%S)"; mkdir -p "$ARCHIV" diff --git a/docs/tp6-marker/server.py b/docs/tp6-marker/server.py index 7496ca0..4816e88 100644 --- a/docs/tp6-marker/server.py +++ b/docs/tp6-marker/server.py @@ -16,21 +16,54 @@ Zwei Aufgaben: - Die Seite hat ein . Ohne JS aktualisiert sich die Anleitung dadurch trotzdem; laeuft JS, entfernt es das Meta-Tag und pollt stattdessen leise. -Aendert nichts am Netz. Bindet auf 0.0.0.0 (Bedienung vom iPad). +🔴 SEIT 2026-08-22 AENDERT DIESE SEITE DOCH ETWAS AM NETZ: der AP-Pin (TP25) schreibt + access-list-Regeln auf den WLAN-Manager. Die Seite bindet auf 0.0.0.0 und kennt keine + Authentifizierung — wer in VLAN10 steht, kann den Pin umschalten. Das ist fuer die Dauer + einer Messung vertretbar und sonst nicht: den Server nach dem Rundgang beenden. Der Pin + selbst ist harmlos genug (er betrifft eine MAC), aber die Regel ist es nicht, die als + naechstes hier landen koennte. + +Bindet auf 0.0.0.0 (Bedienung vom iPhone/iPad). """ import json import os import signal +import subprocess +import threading import urllib.parse import time -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -RUN_DIR = os.environ.get( - "TP6_RUN_DIR", - "/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/" - "0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2", -) -SAMPLER = os.environ.get("TP6_SAMPLER", os.path.join(RUN_DIR, "sampler.csv")) +# 🔴 Das Laufverzeichnis wird NICHT mehr beim Start eingefroren. Grund (2026-08-22): die +# Konsole laeuft als Dauer-Preview auf Port 3031, der Messlauf startet spaeter und legt +# ein neues Verzeichnis an. Vorher schrieb der Server dann brav Marker in den Lauf von +# GESTERN, waehrend der Sampler ins neue Verzeichnis schrieb -- die beiden Zeitachsen +# waren hinterher nicht mehr zusammenzubringen, und man sah es der Seite nicht an. +# Jetzt hinterlaesst tp25-walk-messung.sh einen Zeiger, dem die Konsole folgt. +FALLBACK_RUN_DIR = ("/private/tmp/claude-501/-Users-christophemsenhuber-dev-bf-network-r6/" + "0d86ebd1-035f-4513-a231-5cca8111ecfd/scratchpad/tp6-run2") +POINTER = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".current-run") + + +def run_dir(): + d = os.environ.get("TP6_RUN_DIR") + if d: + return d + try: + z = open(POINTER).read().strip() + if z and os.path.isdir(z): + return z + except Exception: + pass + return FALLBACK_RUN_DIR + + +def _p(name): + return os.path.join(run_dir(), name) + + +def sampler_pfad(): + return os.environ.get("TP6_SAMPLER", _p("sampler.csv")) PORT = int(os.environ.get("TP6_PORT", "3031")) # Raeume fuer die Gehweg-/Dead-Spot-Kartierung. Reihenfolge = Reihenfolge auf der Seite. @@ -40,16 +73,14 @@ ROOMS = [r.strip() for r in os.environ.get( "Küche,Wohnzimmer,Diele UG,Technik UG,Treppe,Kinderzimmer,Schlafzimmer,Diele OG,Arbeitszimmer,Bad,Klo,Technik OG,Terrasse" ).split(",") if r.strip()] -MARKERS = os.path.join(RUN_DIR, "markers.csv") -PIDFILE = os.path.join(RUN_DIR, "flows.pid") -STATE = os.path.join(RUN_DIR, "phase.json") -GO_FLAG = os.path.join(RUN_DIR, "go.flag") -ABORT_FLAG = os.path.join(RUN_DIR, "abort.flag") - -os.makedirs(RUN_DIR, exist_ok=True) -if not os.path.exists(MARKERS): - with open(MARKERS, "w") as fh: - fh.write("ts_epoch,ts_local,marker\n") +def markers_pfad(): + d = run_dir() + os.makedirs(d, exist_ok=True) + pfad = os.path.join(d, "markers.csv") + if not os.path.exists(pfad): + with open(pfad, "w") as fh: + fh.write("ts_epoch,ts_local,marker\n") + return pfad # name, label, css-Klasse, in welchen Modi sichtbar BUTTONS = [ @@ -76,9 +107,9 @@ DEFAULT_STATE = { def read_state(): try: - with open(STATE) as fh: + with open(_p("phase.json")) as fh: st = json.load(fh) - st["driver"] = (time.time() - os.path.getmtime(STATE)) < 20 + st["driver"] = (time.time() - os.path.getmtime(_p("phase.json"))) < 20 return st except Exception: st = dict(DEFAULT_STATE) @@ -87,14 +118,14 @@ def read_state(): def write_marker(name): - with open(MARKERS, "a") as fh: + with open(markers_pfad(), "a") as fh: fh.write("%d,%s,%s\n" % (time.time(), time.strftime("%H:%M:%S"), name)) fh.flush() def last_marker(): try: - rows = [r.strip() for r in open(MARKERS) if r.strip()] + rows = [r.strip() for r in open(markers_pfad()) if r.strip()] if len(rows) < 2: return "—" p = rows[-1].split(",") @@ -106,7 +137,7 @@ def last_marker(): def current_room(): """Letzte Raum-Marke plus Verweildauer — damit auf der Seite steht, was mitgeschrieben wird.""" try: - for row in reversed([r.strip() for r in open(MARKERS) if r.strip()]): + for row in reversed([r.strip() for r in open(markers_pfad()) if r.strip()]): p = row.split(",") if len(p) >= 3 and p[2].startswith("RAUM="): return p[2][5:], int(time.time() - int(p[0])) @@ -118,7 +149,7 @@ def current_room(): def sampler_tail(): """Letzte Sampler-Zeile fuer die Live-Anzeige. Fehlt sie, ist das kein Fehler.""" try: - rows = [r.strip() for r in open(SAMPLER) if r.strip()] + rows = [r.strip() for r in open(sampler_pfad()) if r.strip()] if len(rows) < 2: return {} row = dict(zip(rows[0].split(","), rows[-1].split(","))) @@ -137,7 +168,7 @@ def kill_flows(): """Not-Aus: Saettigungs-Flows sofort beenden.""" killed = 0 try: - pids = [int(p) for p in open(PIDFILE).read().split() if p.strip().isdigit()] + pids = [int(p) for p in open(_p("flows.pid")).read().split() if p.strip().isdigit()] except Exception: pids = [] for pid in pids: @@ -146,12 +177,95 @@ def kill_flows(): killed += 1 except Exception: pass - open(ABORT_FLAG, "w").close() + open(_p("abort.flag"), "w").close() return killed -AP = {"cap-wifi1": "ap1 / 5 GHz", "cap-wifi2": "ap1 / 2,4 GHz", - "cap-wifi3": "ap2 / 5 GHz", "cap-wifi4": "ap2 / 2,4 GHz"} +# 🔴 Hier stand bis 2026-08-22 eine fest verdrahtete Tabelle — und zwar mit VERTAUSCHTEN +# APs (cap-wifi1/2 als ap1 statt ap2). Die Baender stimmten, die Identitaet nicht. Die +# cap-wifiN-Nummern vergibt der Manager dynamisch in der Anmeldereihenfolge der CAPs; +# jede Konstante dazu ist ein Fehler, der auf den naechsten AP-Reboot wartet und dann +# still falsch beschriftet. Deshalb kommt die Zuordnung jetzt vom Geraet. +PIN_SH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tp25-pin-s22.sh") +_apmap = {"stand": 0.0, "karte": {}} +_aplock = threading.Lock() + + +def ap_map(): + """cap-wifiN -> 'ap1 / 5 GHz'. Live vom Manager, 60 s gecacht.""" + with _aplock: + if time.time() - _apmap["stand"] < 60 and _apmap["karte"]: + return _apmap["karte"] + karte = {} + try: + out = subprocess.run(["bash", PIN_SH, "map"], capture_output=True, text=True, + timeout=20).stdout + for zeile in out.splitlines(): + if "=" not in zeile: + continue + iface, wert = zeile.strip().split("=", 1) + ap, _, band = wert.partition("/") + karte[iface] = "%s / %s" % (ap, band.replace("GHz", " GHz")) + except Exception: + pass + with _aplock: + if karte: + _apmap.update(stand=time.time(), karte=karte) + return _apmap["karte"] + + +# Pin-Zustand. Das Umschalten dauert ~8 s (Regeln + Kick + Nachlesen), deshalb laeuft es +# in einem Thread und die Seite zeigt solange "schaltet um". +_pin = {"ziel": None, "busy": False, "msg": "", "stand": 0.0} + + +def pin_lesen(): + if _pin["busy"] or time.time() - _pin["stand"] < 20: + return _pin["ziel"] + try: + _pin["ziel"] = subprocess.run(["bash", PIN_SH, "which"], capture_output=True, + text=True, timeout=20).stdout.strip() or None + _pin["stand"] = time.time() + except Exception: + pass + return _pin["ziel"] + + +def pin_setzen(ziel): + def lauf(): + try: + r = subprocess.run(["bash", PIN_SH, ziel], capture_output=True, text=True, timeout=60) + _pin["msg"] = (r.stdout.strip().splitlines() or [""])[0] + except Exception as e: + _pin["msg"] = "Fehler: %s" % e + finally: + _pin["busy"] = False + _pin["stand"] = 0.0 + pin_lesen() + _pin["busy"] = True + _pin["msg"] = "schaltet um …" + # Der Klick gehoert in dieselbe Zeitachse wie alles andere — sonst laesst sich hinterher + # nicht sagen, welche Messpunkte zu welchem AP gehoeren. + write_marker("PIN=%s" % ziel) + threading.Thread(target=lauf, daemon=True).start() + + +def lokale_adressen(): + """Alle IPv4-Adressen dieses Rechners ausser Loopback/Link-Local, als (iface, ip).""" + treffer = [] + try: + out = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=10).stdout + iface = "?" + for zeile in out.splitlines(): + if zeile and not zeile[0].isspace(): + iface = zeile.split(":", 1)[0] + elif zeile.strip().startswith("inet "): + ip = zeile.split()[1] + if not ip.startswith(("127.", "169.254.")): + treffer.append((iface, ip)) + except Exception: + pass + return treffer def fmt_bps(v): @@ -168,7 +282,20 @@ def render(st): mode = st.get("mode", "wartet") banner = "" - if not st.get("driver"): + alarm = False # nur DIESER Zustand loest Ton + Vibration aus, nicht jeder Banner + # 🔴 Der wichtigste Alarm der Seite, seit es den AP-Pin gibt (2026-08-22): ein vom Pin + # ABGEWIESENER Verbindungsversuch ist in den Ping-Daten nicht von einem Funkloch zu + # unterscheiden -- und der Manager protokolliert die Abweisung auf info-Ebene nicht. + # Android quittiert sie mit "Verbindung fehlgeschlagen" und WAEHLT DAS NETZ AB, danach + # misst der restliche Rundgang nur noch Stille. Deshalb wird "haengt nirgends" hier + # nicht klein in der WLAN-Zeile gezeigt, sondern schreit. + if pin_lesen() in ("ap1", "ap2") and w.get("iface") == "absent" and w.get("age", 999) < 30: + alarm = True + banner = ('
') + elif not st.get("driver"): banner = ('') elif mode == "langzeit": @@ -185,9 +312,35 @@ def render(st): wlan = "—" if w.get("iface"): wlan = "%s · %s dBm · tx %s / rx %s" % ( - AP.get(w["iface"], w["iface"]), w.get("signal", "?"), + ap_map().get(w["iface"], w["iface"]), w.get("signal", "?"), fmt_bps(w.get("sta_tx_bps")), fmt_bps(w.get("sta_rx_bps"))) + ziel = pin_lesen() + if _pin["busy"]: + pinstatus = "⏳ %s" % _pin["msg"] + elif ziel in ("ap1", "ap2"): + pinstatus = "S22 haengt fest auf %s" % ziel + elif ziel == "off": + pinstatus = "kein Pin — S22 roamt frei" + else: + pinstatus = "Zustand unbekannt (Manager nicht erreichbar?)" + pinkarte = ( + '