Phase 4: WLAN-Kanalanalyse — Kanalgraph, Kanalempfehlung, Momentaufnahmen

Bisher zeigte die App nur eine Liste sichtbarer Netze ohne Kanalgraph, ohne
Empfehlung und ohne Möglichkeit, einen Kanalwechsel vorher/nachher zu
vergleichen. Fester 800-ms-Sleep statt auf den echten Scan zu warten, keine
Reaktion auf ausgeschaltete Ortung, Standortberechtigung auch dort nötig, wo
Android 13+ das gar nicht mehr verlangt.

Nativ (NetDiagScannerPlugin.kt):
- wifiScan liefert jetzt frequency, widthMhz, centerFreq0/1, standard
  (Wi-Fi 4/5/6/7), security (offen/WEP/WPA/WPA2/WPA3), ageMs, band
  (inkl. 6 GHz) je Netz. freqToChannel() um das 6-GHz-Band erweitert
  (inkl. Sonderfall Kanal 2/PSC).
- wifiScan({trigger:true}) löst einen frischen Scan aus und wartet auf
  SCAN_RESULTS_AVAILABLE_ACTION (5s Zeitgrenze) statt blind zu schlafen.
- isLocationEnabled()-Prüfung + openLocationSettings(): ausgeschaltete
  Ortung (< Android 13) ergibt jetzt einen Klartextfehler mit direktem
  Weg zu den Einstellungen statt einer stillen leeren Liste.
- NEARBY_WIFI_DEVICES (API 33+, neverForLocation) statt Standortrecht —
  eigenes Berechtigungs-Alias, wifiScanPermissionAlias() wählt je nach
  SDK-Version; gilt für wifiScan/startWifiScan/startWifiTrack gemeinsam.

App:
- WifiChannelChart.svelte: Kanalgraph 2,4/5/6 GHz als Inline-SVG — Bögen
  mit Kanalbreite als Bogenbreite, Signalstärke als Bogenhöhe, Überlapp
  (Co-/Adjacent-Channel) direkt als sichtbare Farbverdichtung.
- wifi/recommend.ts: beste der drei nicht überlappenden 2,4-GHz-Kanäle
  nach Störlast + zwei feste Warnregeln (40 MHz im 2,4-GHz-Band,
  160 MHz/DFS im 5-GHz-Band).
- wifi/rating.ts: rssiRating() vereinheitlicht — ersetzt drei Kopien mit
  unterschiedlichen Schwellen (iptest/+page.svelte, wifi/+page.svelte
  zweimal).
- Neue Route /protokoll/{id}/wifikanal/ + Werkzeug-Kachel "WLAN-Kanäle":
  Netzliste nach SSID gruppiert (Mesh), Momentaufnahmen speichern
  (protocol.wifiSurveys, nur lokal), automatischer Kanaländerungs-Hinweis
  seit der letzten Aufnahme, Zeitverlauf mehrerer Netze aus den
  gespeicherten Momentaufnahmen (echte Zeitstempel, feste Y-Skala
  -100..-20 dBm, Messlücken reißen die Linie statt sie zu überbrücken).

Im Emulator getestet (API 33+): NEARBY_WIFI_DEVICES-Berechtigungsdialog
erscheint und wird korrekt verarbeitet — echte Bestätigung, kein
Codereview-only. Leerer Netz-Zustand rendert sauber. Der AVD hat keine
echte WLAN-Hardware (liefert grundsätzlich 0 Scan-Ergebnisse) — Kanalgraph
mit echten Netzen und der Ortung-aus-Zweig (<Android 13, auf diesem API-33-
AVD unerreichbar) konnten deshalb nicht live geprüft werden, nur die
Chart-Mathematik gegen die Mock-Werte durchgerechnet (keine NaN/Werte
außerhalb des Zeichenbereichs). Sollte auf einem echten Handy mit echtem
WLAN-Empfang gegengeprüft werden.

Details: ROADMAP_UMSETZUNG.md Phase 4
This commit is contained in:
Eduard Wisch 2026-08-15 16:16:15 +02:00
parent 7fdf693f36
commit 39a0507f99
13 changed files with 960 additions and 46 deletions

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
@ -50,6 +51,16 @@
<!-- Multicast-Lock für die mDNS-/Bonjour-Dienstsuche (NsdManager) -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!--
Ab Android 13 (API 33) kann WLAN-Scan ohne Standortrecht laufen, wenn
die App "neverForLocation" setzt (wir werten BSSID/RSSI aus, nicht die
Position). Spart den Nutzern den Ortungs-Berechtigungsdialog komplett;
auf älteren Geräten bleibt ACCESS_FINE_LOCATION der einzige Weg.
-->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den

View file

@ -6,6 +6,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
@ -74,7 +75,11 @@ import java.util.concurrent.TimeUnit
@CapacitorPlugin(
name = "NetDiagScanner",
permissions = [
Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION])
Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION]),
// Ab Android 13 (API 33) reicht dieses Recht für WLAN-Scan, ganz ohne
// dass der Nutzer "Ortung erlauben" bestätigen muss (siehe
// wifiScanPermissionAlias()).
Permission(alias = "nearbyWifi", strings = ["android.permission.NEARBY_WIFI_DEVICES"]),
]
)
class NetDiagScannerPlugin : Plugin() {
@ -967,10 +972,64 @@ class NetDiagScannerPlugin : Plugin() {
/* WLAN-Scan */
/* --------------------------------------------------------------------- */
/**
* Ab Android 13 (API 33) reicht `NEARBY_WIFI_DEVICES` (mit
* `neverForLocation`) für WLAN-Scan kein Ortungsdialog mehr nötig.
* Ältere Geräte kennen dieses Recht nicht, dort bleibt `ACCESS_FINE_LOCATION`
* der einzige Weg (Android-Vorgabe, nicht änderbar).
*/
private fun wifiScanPermissionAlias(): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) "nearbyWifi" else "location"
private fun hasWifiScanPermission(): Boolean =
getPermissionState(wifiScanPermissionAlias()) == com.getcapacitor.PermissionState.GRANTED
/**
* Auf < Android 13 liefert ein WLAN-Scan mit korrekt erteiltem Standortrecht
* trotzdem eine LEERE Liste, wenn der System-Ortungsschalter selbst aus ist
* eine App-Berechtigung allein reicht dafür nicht. Ohne diese Prüfung sah
* das wie ein kaputter Scan aus ("0 Netze gefunden"), obwohl alles korrekt
* konfiguriert war.
*/
private fun isLocationEnabled(): Boolean {
val lm = context.applicationContext.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
?: return true
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
lm.isLocationEnabled
} else {
@Suppress("DEPRECATION")
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
} catch (_: Exception) {
true
}
}
/** Öffnet die System-Ortungseinstellungen (für den Klartext-Hinweis bei `isLocationEnabled()==false`). */
@PluginMethod
fun openLocationSettings(call: PluginCall) {
try {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.applicationContext.startActivity(intent)
resolve(call, JSObject().put("ok", true))
} catch (e: Exception) {
call.reject("openLocationSettings: ${e.message}")
}
}
/**
* WLAN-Netze auflisten. Mit `trigger=true` wird vorher ein frischer Scan
* angestoßen und auf `SCAN_RESULTS_AVAILABLE_ACTION` gewartet (mit
* Zeitgrenze) statt wie bisher blind 800 ms zu schlafen und zu hoffen,
* dass der Cache bis dahin gefüllt ist.
*/
@PluginMethod
fun wifiScan(call: PluginCall) {
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
requestPermissionForAlias("location", call, "wifiScanPermCallback")
if (!hasWifiScanPermission()) {
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiScanPermCallback")
return
}
doWifiScan(call)
@ -978,29 +1037,70 @@ class NetDiagScannerPlugin : Plugin() {
@PermissionCallback
private fun wifiScanPermCallback(call: PluginCall) {
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
if (hasWifiScanPermission()) {
doWifiScan(call)
} else {
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
call.reject("Berechtigung für WLAN-Scan abgelehnt")
}
}
private fun doWifiScan(call: PluginCall) {
try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val arr = JSArray()
for (r in wifi.scanResults) {
val freq = r.frequency
arr.put(JSObject()
.put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID)
.put("bssid", r.BSSID ?: "")
.put("channel", freqToChannel(freq))
.put("rssi", r.level)
.put("band", if (freq > 4000) "5 GHz" else "2.4 GHz"))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && !isLocationEnabled()) {
call.reject("Standort ist aus — auf diesem Android für WLAN-Scan nötig. Unter Einstellungen > Standort aktivieren.")
return
}
io.launch {
try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
var timedOut = false
if (call.getBoolean("trigger") == true) {
val latch = CountDownLatch(1)
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action == WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) latch.countDown()
}
}
context.applicationContext.registerReceiver(
receiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION),
)
try {
@Suppress("DEPRECATION") val started = wifi.startScan()
if (started) {
timedOut = withContext(Dispatchers.IO) { !latch.await(5000, TimeUnit.MILLISECONDS) }
}
} finally {
try { context.applicationContext.unregisterReceiver(receiver) } catch (_: Exception) { }
}
}
val arr = JSArray()
val nowElapsedUs = android.os.SystemClock.elapsedRealtime() * 1000L
for (r in wifi.scanResults) {
val freq = r.frequency
val ageMs = ((nowElapsedUs - r.timestamp) / 1000L).coerceAtLeast(0L)
val dev = JSObject()
.put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID)
.put("bssid", r.BSSID ?: "")
.put("channel", freqToChannel(freq))
.put("frequency", freq)
.put("rssi", r.level)
.put("band", bandLabel(freq))
.put("security", securityLabel(r.capabilities ?: ""))
.put("ageMs", ageMs)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
dev.put("widthMhz", channelWidthMhz(r.channelWidth))
if (r.centerFreq0 > 0) dev.put("centerFreq0", r.centerFreq0)
if (r.centerFreq1 > 0) dev.put("centerFreq1", r.centerFreq1)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val std = wifiStandardLabel(r.wifiStandard)
if (std.isNotEmpty()) dev.put("standard", std)
}
arr.put(dev)
}
resolve(call, JSObject().put("networks", arr).put("timedOut", timedOut))
} catch (e: Exception) {
call.reject("wifiScan: ${e.message}")
}
resolve(call, JSObject().put("networks", arr))
} catch (e: Exception) {
call.reject("wifiScan: ${e.message}")
}
}
@ -1797,8 +1897,8 @@ class NetDiagScannerPlugin : Plugin() {
*/
@PluginMethod
fun startWifiScan(call: PluginCall) {
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
requestPermissionForAlias("location", call, "startWifiScanPermCallback")
if (!hasWifiScanPermission()) {
requestPermissionForAlias(wifiScanPermissionAlias(), call, "startWifiScanPermCallback")
return
}
doStartWifiScan(call)
@ -1806,10 +1906,10 @@ class NetDiagScannerPlugin : Plugin() {
@PermissionCallback
private fun startWifiScanPermCallback(call: PluginCall) {
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
if (hasWifiScanPermission()) {
doStartWifiScan(call)
} else {
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
call.reject("Berechtigung für WLAN-Scan abgelehnt")
}
}
@ -1835,8 +1935,8 @@ class NetDiagScannerPlugin : Plugin() {
fun startWifiTrack(call: PluginCall) {
val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt")
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
requestPermissionForAlias("location", call, "wifiTrackPermCallback")
if (!hasWifiScanPermission()) {
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiTrackPermCallback")
return
}
doStartWifiTrack(call, bssid, intervalMs)
@ -1844,12 +1944,12 @@ class NetDiagScannerPlugin : Plugin() {
@PermissionCallback
private fun wifiTrackPermCallback(call: PluginCall) {
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
if (hasWifiScanPermission()) {
val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt")
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
doStartWifiTrack(call, bssid, intervalMs)
} else {
call.reject("Standortberechtigung für WLAN-Tracker abgelehnt")
call.reject("Berechtigung für WLAN-Tracker abgelehnt")
}
}
@ -2213,9 +2313,51 @@ class NetDiagScannerPlugin : Plugin() {
freq == 2484 -> 14
freq in 2412..2472 -> (freq - 2412) / 5 + 1
freq in 5170..5825 -> (freq - 5170) / 5 + 34
freq == 5935 -> 2 // Sonderfall 6-GHz-Kanal 2 (PSC) — nicht der 5-MHz-Rasterformel unten
freq in 5925..7125 -> (freq - 5950) / 5
else -> 0
}
/** Frequenzband als Klartext — 6-GHz-Bereich (Wi-Fi 6E) ergänzt. */
private fun bandLabel(freq: Int): String = when {
freq >= 5925 -> "6 GHz"
freq > 4000 -> "5 GHz"
else -> "2.4 GHz"
}
/** `ScanResult.channelWidth`-Konstante (API 23+) in MHz umrechnen. */
private fun channelWidthMhz(cw: Int): Int = when (cw) {
0 -> 20 // CHANNEL_WIDTH_20MHZ
1 -> 40 // CHANNEL_WIDTH_40MHZ
2 -> 80 // CHANNEL_WIDTH_80MHZ
3 -> 160 // CHANNEL_WIDTH_160MHZ
4 -> 160 // CHANNEL_WIDTH_80MHZ_PLUS_MHZ (80+80, gilt wie 160 fürs Belegungsband)
5 -> 320 // CHANNEL_WIDTH_320MHZ (API 33+, Wi-Fi 6E)
else -> 20
}
/** `ScanResult.wifiStandard`-Konstante (API 30+) als Klartext. */
private fun wifiStandardLabel(std: Int): String = when (std) {
1 -> "802.11a/b/g" // WIFI_STANDARD_LEGACY
4 -> "Wi-Fi 4 (802.11n)"
5 -> "Wi-Fi 5 (802.11ac)"
6 -> "Wi-Fi 6 (802.11ax)"
7 -> "Wi-Fi 7 (802.11be)"
else -> ""
}
/** `ScanResult.capabilities` (z.B. "[WPA2-PSK-CCMP][ESS]") in eine kurze Sicherheitsangabe wandeln. */
private fun securityLabel(capabilities: String): String {
val c = capabilities.uppercase()
return when {
c.contains("WPA3") -> "WPA3"
c.contains("WPA2") -> "WPA2"
c.contains("WPA") -> "WPA"
c.contains("WEP") -> "WEP"
else -> "offen"
}
}
private fun serviceName(port: Int): String = when (port) {
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"

View file

@ -0,0 +1,128 @@
<script lang="ts">
/**
* Kanalgraph für ein Frequenzband — jedes Netz als Parabel-Bogen, zentriert
* auf seine Frequenz, mit der Kanalbreite als Bogenbreite und der
* Signalstärke als Bogenhöhe. Überlappende Bögen (Co-Channel oder
* Adjacent-Channel) sind halbtransparent gefüllt, sodass sich Überlapp
* direkt als sichtbare Farbverdichtung zeigt — genau das Bild, das die
* "WiFi Analyzer"-Vorbild-Apps zeigen. Bewusst OHNE externe Chart-Library
* (Inline-SVG, Projektkonvention).
*/
import type { WifiNetwork } from '$lib/scanner';
let {
networks,
band,
highlightBssid = undefined,
}: {
networks: WifiNetwork[];
band: '2.4 GHz' | '5 GHz' | '6 GHz';
/** eigenes/verbundenes Netz optisch hervorheben */
highlightBssid?: string;
} = $props();
const W = 320;
const H = 130;
const BASELINE = H - 18;
const PEAK_MAX = BASELINE - 8;
// Frequenzbereich + Kanal-Ticks je Band
const RANGE: Record<string, { min: number; max: number; ticks: number[] }> = {
'2.4 GHz': {
min: 2402,
max: 2482,
ticks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
},
'5 GHz': {
min: 5150,
max: 5895,
ticks: [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165],
},
'6 GHz': {
min: 5925,
max: 7125,
ticks: [1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73, 77, 81, 85, 89, 93],
},
};
const view = $derived.by(() => {
const r = RANGE[band];
const list = networks.filter((n) => n.band === band);
if (!r) return null;
const freqSpan = r.max - r.min;
const x = (freq: number) => ((freq - r.min) / freqSpan) * W;
// -90 dBm (schwach) -> flacher Bogen, -30 dBm (stark) -> fast bis zum Rand
const peakY = (rssi: number) => {
const t = Math.min(1, Math.max(0, (rssi + 90) / 60));
return BASELINE - t * (PEAK_MAX - 0) - 4;
};
const bumps = list.map((n) => {
const widthMhz = n.widthMhz ?? 20;
const halfWidthPx = Math.max(4, (widthMhz / 2 / freqSpan) * W);
const cx = x(n.frequency);
const py = peakY(n.rssi);
return {
n,
cx,
halfWidthPx,
py,
path: `M ${(cx - halfWidthPx).toFixed(1)},${BASELINE} Q ${cx.toFixed(1)},${py.toFixed(1)} ${(cx + halfWidthPx).toFixed(1)},${BASELINE} Z`,
isSelf: highlightBssid != null && n.bssid === highlightBssid,
};
});
// Kanal-Ticks: nur die tatsächlich im Bereich liegenden, sonst wird die
// Achse bei 5/6 GHz (viele mögliche Kanäle) unleserlich.
const chFreq2_4 = (ch: number) => (ch === 14 ? 2484 : 2412 + (ch - 1) * 5);
const chFreq5 = (ch: number) => 5170 + (ch - 34) * 5;
const chFreq6 = (ch: number) => 5950 + ch * 5;
const freqForTick = band === '2.4 GHz' ? chFreq2_4 : band === '5 GHz' ? chFreq5 : chFreq6;
const ticks = r.ticks
.map((ch) => ({ ch, fx: freqForTick(ch) }))
.filter((t) => t.fx >= r.min && t.fx <= r.max)
.map((t) => ({ ...t, x: x(t.fx) }));
return { bumps, ticks };
});
</script>
{#if view}
{#if view.bumps.length === 0}
<div class="flex h-[90px] items-center justify-center text-xs text-zinc-600">Keine Netze im {band}-Band</div>
{:else}
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {band}">
<!-- Grundlinie -->
<line x1="0" y1={BASELINE} x2={W} y2={BASELINE} stroke="currentColor" class="text-zinc-800" stroke-width="1" />
<!-- Kanal-Ticks -->
{#each view.ticks as t (t.ch)}
<line x1={t.x} y1={BASELINE} x2={t.x} y2={BASELINE + 3} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
<text x={t.x} y={H - 4} text-anchor="middle" class="fill-zinc-600" style="font-size: 7px">{t.ch}</text>
{/each}
<!-- Bögen: schwache Netze zuerst zeichnen, damit starke oben liegen -->
{#each [...view.bumps].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
<path
d={b.path}
class={b.isSelf ? 'fill-sky-500/40 stroke-sky-400' : 'fill-zinc-400/25 stroke-zinc-400/70'}
stroke-width="1"
/>
{/each}
<!-- Beschriftung am Scheitelpunkt -->
{#each view.bumps as b (b.n.bssid)}
<text
x={b.cx}
y={Math.max(9, b.py - 3)}
text-anchor="middle"
class={b.isSelf ? 'fill-sky-300 font-medium' : 'fill-zinc-400'}
style="font-size: 7px"
>
{b.n.ssid || b.n.bssid.slice(-5)}
</text>
{/each}
</svg>
{/if}
{/if}

View file

@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol {
p.savedScans ??= [];
p.monitorSessions ??= [];
p.wifiTrackSessions ??= [];
p.wifiSurveys ??= [];
p.stressTestSessions ??= [];
return p;
}

View file

@ -8,6 +8,7 @@ import type {
Measurement,
Protocol,
SavedScan,
WifiSurvey,
WifiTrackSession,
} from './types';
@ -121,3 +122,9 @@ export function addWifiTrackSession(
(protocol.wifiTrackSessions ??= []).push(s);
return s;
}
/** Kanal-Momentaufnahme zum Protokoll hinzufügen (für Vorher/Nachher-Vergleich beim Kanalwechsel) */
export function addWifiSurvey(protocol: Protocol, s: WifiSurvey): WifiSurvey {
(protocol.wifiSurveys ??= []).push(s);
return s;
}

View file

@ -165,6 +165,17 @@ export interface WifiNetwork {
channel: number;
rssi: number;
band: string;
frequency: number;
/** Kanalbreite in MHz (20/40/80/160/320) — fehlt vor Android 6 */
widthMhz?: number;
centerFreq0?: number;
centerFreq1?: number;
/** z.B. "Wi-Fi 6 (802.11ax)" — nur ab Android 11 (API 30) gemeldet */
standard?: string;
/** "offen" | "WEP" | "WPA" | "WPA2" | "WPA3" */
security?: string;
/** wie alt das Scan-Ergebnis ist (ms) — alte Cache-Treffer sind kein Grund zur Sorge, wohl aber irreführend, wenn sie als "gerade jetzt" verstanden werden */
ageMs?: number;
}
export interface DhcpLease {
/** DHCP-Server, von dem das Gerät seine IP bezieht ('' wenn unbekannt) */
@ -232,8 +243,18 @@ export interface NetDiagScannerPlugin {
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
pingQuality(opts: { host: string; count: number }): Promise<PingQuality>;
/** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */
wifiScan(): Promise<{ networks: WifiNetwork[] }>;
/**
* WLAN-Scan: umliegende Netze, Kanäle, Signalstärke. Mit `trigger: true`
* wird vorher ein frischer Scan angestoßen und auf das tatsächliche
* Scan-Ende gewartet (statt fest 800 ms zu schlafen und zu hoffen).
*/
wifiScan(opts?: { trigger?: boolean }): Promise<{
networks: WifiNetwork[];
/** true = auf einen angeforderten frischen Scan wurde vergeblich gewartet (Timeout) — Ergebnis ist dann der letzte Cache-Stand */
timedOut?: boolean;
}>;
/** Öffnet die System-Ortungseinstellungen — für den Hinweis, wenn WLAN-Scan wegen ausgeschalteter Ortung leer bleibt (vor Android 13) */
openLocationSettings(): Promise<{ ok: boolean }>;
/** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
dhcpInfo(): Promise<DhcpLease>;
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
@ -548,14 +569,81 @@ const mock: NetDiagScannerPlugin = {
};
},
async wifiScan() {
// Bewusst mit ein paar "Fallstricken" bestückt, damit sich Kanalgraph,
// Gruppierung und Warnregeln auch im Browser-Dev ohne Gerät zeigen:
// Mesh (gleiche SSID auf zwei BSSIDs), ein 40-MHz-Netz im 2,4-GHz-Band
// (Warnregel), ein DFS/160-MHz-Netz im 5-GHz-Band (Warnregel).
return {
networks: [
{ ssid: 'AllesWattLaeuft', bssid: 'AA:BB:CC:11:22:33', channel: 6, rssi: -52, band: '2.4 GHz' },
{ ssid: 'Nachbar-WLAN', bssid: 'DD:EE:FF:44:55:66', channel: 11, rssi: -78, band: '2.4 GHz' },
{ ssid: 'AllesWattLaeuft-5G', bssid: 'AA:BB:CC:11:22:34', channel: 36, rssi: -58, band: '5 GHz' },
{
ssid: 'AllesWattLaeuft',
bssid: 'AA:BB:CC:11:22:33',
channel: 6,
frequency: 2437,
rssi: -52,
band: '2.4 GHz',
widthMhz: 20,
standard: 'Wi-Fi 5 (802.11ac)',
security: 'WPA2',
ageMs: 1200,
},
{
// Mesh-Knoten desselben Netzes, eigener Kanal
ssid: 'AllesWattLaeuft',
bssid: 'AA:BB:CC:11:22:99',
channel: 1,
frequency: 2412,
rssi: -68,
band: '2.4 GHz',
widthMhz: 20,
standard: 'Wi-Fi 5 (802.11ac)',
security: 'WPA2',
ageMs: 3400,
},
{
ssid: 'AllesWattLaeuft-5G',
bssid: 'AA:BB:CC:11:22:34',
channel: 36,
frequency: 5180,
rssi: -58,
band: '5 GHz',
widthMhz: 160,
centerFreq0: 5250,
standard: 'Wi-Fi 6 (802.11ax)',
security: 'WPA3',
ageMs: 900,
},
{
ssid: 'Nachbar-WLAN',
bssid: 'DD:EE:FF:44:55:66',
channel: 9,
frequency: 2452,
rssi: -78,
band: '2.4 GHz',
// 40 MHz im 2,4-GHz-Band überlappt praktisch immer mit Nachbarn — Warnregel
widthMhz: 40,
standard: 'Wi-Fi 4 (802.11n)',
security: 'WPA2',
ageMs: 5000,
},
{
ssid: 'FritzBox-Gast',
bssid: '11:22:33:44:55:66',
channel: 11,
frequency: 2462,
rssi: -82,
band: '2.4 GHz',
widthMhz: 20,
security: 'offen',
ageMs: 6100,
},
],
timedOut: false,
};
},
async openLocationSettings() {
return { ok: true };
},
async dhcpInfo() {
return {
server: '192.168.1.1',

View file

@ -190,6 +190,36 @@ export interface StressBucket {
lossPct: number;
}
/** Ein WLAN-Netz innerhalb einer Kanal-Momentaufnahme (`WifiSurvey`) */
export interface WifiSurveyNetwork {
ssid: string;
bssid: string;
channel: number;
frequency: number;
band: string;
rssi: number;
widthMhz?: number;
centerFreq0?: number;
centerFreq1?: number;
standard?: string;
security?: string;
}
/**
* Momentaufnahme ALLER sichtbaren WLAN-Netze zu einem Zeitpunkt (Kanäle,
* Breite, Signalstärke) Grundlage für den Kanalgraph, den Zeitverlauf
* mehrerer Netze (mehrere Momentaufnahmen übereinandergelegt) und den
* Vorher/Nachher-Vergleich beim Kanalwechsel. NUR LOKAL, nicht synchronisiert
* (wie WifiTrackSession) reine Vor-Ort-Diagnosehilfe.
*/
export interface WifiSurvey {
id: string;
createdAt: number;
/** benutzervergebene Bezeichnung, z.B. "vor Kanalwechsel" */
label?: string;
networks: WifiSurveyNetwork[];
}
/**
* Laufende oder gerade beendete Dauer-/Stresstest-Sitzung NUR LOKAL
* (nicht synchronisiert, wie DeviceMonitorSession/WifiTrackSession). Dient der
@ -267,6 +297,8 @@ export interface Protocol {
monitorSessions?: DeviceMonitorSession[];
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
wifiTrackSessions?: WifiTrackSession[];
/** WLAN-Kanal-Momentaufnahmen (nur lokal, wird nicht synchronisiert) */
wifiSurveys?: WifiSurvey[];
/** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */
stressTestSessions?: StressTestSession[];
/** true solange noch nicht zum Server synchronisiert */

31
src/lib/wifi/rating.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Gemeinsame WLAN-Empfangsbewertung (RSSI in dBm).
*
* Vorher gab es drei Kopien mit unterschiedlichen Schwellen: `iptest/+page.svelte`
* und `wifi/+page.svelte` hatten je eine eigene `rssiColor()` (beide -55/-70,
* zufällig gleich), UND `wifi/+page.svelte` hatte zusätzlich eine `rssiLabel()`
* mit eigenen, davon abweichenden Schwellen (-55/-65/-75) derselbe Messwert
* konnte je nach Stelle unterschiedlich benannt werden. Diese Funktion ist jetzt
* die einzige Stelle, die entscheidet.
*
* Schwellen orientieren sich an gängigen WLAN-Planungswerten: ab -55 dBm
* exzellent, ab -67 dBm noch zuverlässig für Video/Voice, ab -75 dBm gerade
* noch brauchbar (Web/E-Mail), darunter unzuverlässig.
*/
import type { MeasureStatus } from '../types';
export interface RssiRating {
label: string;
colorClass: string;
/** Ampel-Bewertung — 0 ok, 1 warn, 2 fail */
status: MeasureStatus;
}
export function rssiRating(rssi: number | undefined | null): RssiRating {
if (rssi == null) return { label: '—', colorClass: 'text-zinc-500', status: 1 };
if (rssi >= -55) return { label: 'sehr gut', colorClass: 'text-emerald-400', status: 0 };
if (rssi >= -67) return { label: 'gut', colorClass: 'text-emerald-400', status: 0 };
if (rssi >= -75) return { label: 'mäßig', colorClass: 'text-amber-400', status: 1 };
return { label: 'schwach', colorClass: 'text-red-400', status: 2 };
}

89
src/lib/wifi/recommend.ts Normal file
View file

@ -0,0 +1,89 @@
/**
* Kanalempfehlung und Warnregeln fürs 2,4-/5-GHz-Band.
*
* Bewusst einfach gehalten (keine vollständige RF-Simulation): zählt für
* jeden der drei nicht überlappenden 2,4-GHz-Kanäle (1/6/11) die Störlast
* durch alle sichtbaren Netze Co-Channel (gleicher Kanal) zählt voll,
* Adjacent-Channel (überlappender Bereich, z.B. Kanal 4 bei Kanal 6) zählt
* abgeschwächt nach Abstand. Gewichtet zusätzlich mit der Signalstärke des
* störenden Netzes (ein schwaches Fremdnetz stört weniger als ein starkes).
*/
import type { WifiNetwork } from '../scanner';
const NONOVERLAPPING_24 = [1, 6, 11];
/** Kanalabstand zweier 2,4-GHz-Kanäle in "Kanal-Einheiten" (5 MHz je Kanal) */
function channelDistance24(a: number, b: number): number {
return Math.abs(a - b);
}
/**
* Störlast eines Kandidatenkanals durch ein einzelnes Netz 1.0 bei
* Co-Channel, linear abnehmend bis 0 bei einem Abstand von 5 Kanälen
* (= kein spürbarer Überlapp mehr bei 20-MHz-Kanälen), gewichtet mit der
* Signalstärke (stärkeres Fremdnetz stört mehr).
*/
function interferenceWeight(candidate: number, network: WifiNetwork): number {
const dist = channelDistance24(candidate, network.channel);
if (dist >= 5) return 0;
const overlapFactor = 1 - dist / 5;
// RSSI -30 (sehr stark) -> Gewicht ~1, RSSI -90 (kaum sichtbar) -> Gewicht ~0.1
const strengthFactor = Math.min(1, Math.max(0.1, (network.rssi + 90) / 60));
return overlapFactor * strengthFactor;
}
export interface ChannelScore {
channel: number;
/** niedriger = besser (weniger Störlast) */
load: number;
}
/**
* Beste der drei nicht überlappenden 2,4-GHz-Kanäle (1/6/11), aufsteigend
* nach Störlast sortiert. `networks` sollte NUR Fremdnetze enthalten (das
* eigene Netz selbst zählt nicht als Störer für sich selbst).
*/
export function recommend24GhzChannels(networks: WifiNetwork[]): ChannelScore[] {
const in24 = networks.filter((n) => n.band === '2.4 GHz');
return NONOVERLAPPING_24.map((channel) => ({
channel,
load: Math.round(in24.reduce((sum, n) => sum + interferenceWeight(channel, n), 0) * 100) / 100,
})).sort((a, b) => a.load - b.load);
}
/**
* Zwei feste Warnregeln, die praktisch immer ein Problem anzeigen:
* 1. 40 MHz Kanalbreite im 2,4-GHz-Band bei realistischer Nachbarschafts-
* dichte (Mehrfamilienhaus, Gewerbegebiet) fast immer eine Interferenz-
* quelle, weil es 2 der 3 nicht überlappenden Kanäle gleichzeitig belegt.
* 2. 160 MHz Kanalbreite (oder DFS-Kanäle 52-144) im 5-GHz-Band DFS kann
* durch Radar-Erkennung jederzeit einen Kanalwechsel erzwingen (kurzer
* Aussetzer), 160 MHz ist auf vielen Consumer-Geräten instabil bzw. wird
* bei Störung automatisch auf 80 MHz zurückgestuft.
*/
export function channelWarnings(networks: WifiNetwork[]): string[] {
const warnings: string[] = [];
const wide24 = networks.filter((n) => n.band === '2.4 GHz' && (n.widthMhz ?? 20) >= 40);
for (const n of wide24) {
warnings.push(
`${n.ssid || n.bssid}" nutzt 40 MHz im 2,4-GHz-Band (Kanal ${n.channel}) — blockiert damit ` +
`praktisch 2 der 3 störungsfreien Kanäle. In dicht besiedelter Umgebung meist ein Fehler in ` +
`der Router-Konfiguration, nicht Absicht.`,
);
}
const isDfsChannel = (ch: number) => ch >= 52 && ch <= 144;
const wideOrDfs5 = networks.filter(
(n) => n.band === '5 GHz' && ((n.widthMhz ?? 20) >= 160 || isDfsChannel(n.channel)),
);
for (const n of wideOrDfs5) {
const reasons: string[] = [];
if ((n.widthMhz ?? 20) >= 160) reasons.push('160 MHz Kanalbreite');
if (isDfsChannel(n.channel)) reasons.push('DFS-Kanal (Radar-Erkennung kann Kanalwechsel erzwingen)');
warnings.push(`${n.ssid || n.bssid}" (Kanal ${n.channel}): ${reasons.join(', ')} — kann zu kurzen, scheinbar grundlosen Aussetzern führen.`);
}
return warnings;
}

View file

@ -360,6 +360,17 @@
Empfangsstärke beim Durchgehen aufzeichnen.
</span>
</a>
<!-- WLAN-Kanalanalyse: alle Netze auf einen Blick, Kanalgraph -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/wifikanal/"
>
<Icons.Radio size={20} class="text-sky-400" />
<span class="text-sm font-medium">WLAN-Kanäle</span>
<span class="text-[11px] leading-tight text-zinc-500">
Kanalbelegung, Überlappung und Kanalempfehlung — alle Netze auf einen Blick.
</span>
</a>
</div>
</section>

View file

@ -17,6 +17,7 @@
import { getProtocol, saveProtocol } from '$lib/db';
import { addMeasurement } from '$lib/protocols';
import { scanner } from '$lib/scanner';
import { rssiRating } from '$lib/wifi/rating';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { LinkInfo, Protocol } from '$lib/types';
@ -85,10 +86,7 @@
}
function rssiColor(rssi?: number): string {
if (rssi == null) return 'text-zinc-500';
if (rssi >= -55) return 'text-emerald-400';
if (rssi >= -70) return 'text-amber-400';
return 'text-red-400';
return rssiRating(rssi).colorClass;
}
async function persist(): Promise<void> {

View file

@ -20,6 +20,7 @@
type WifiNetwork,
type WifiSignalEvent,
} from '$lib/scanner';
import { rssiRating } from '$lib/wifi/rating';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types';
@ -185,19 +186,14 @@
/* --- Anzeige-Helfer --- */
// Dünne Wrapper um die gemeinsame rssiRating() — behalten die bisherigen
// Aufrufstellen unten unverändert (colorClass/label statt eines Objekts).
function rssiColor(rssi: number | undefined): string {
if (rssi == null) return 'text-zinc-500';
if (rssi >= -55) return 'text-emerald-400';
if (rssi >= -70) return 'text-amber-400';
return 'text-red-400';
return rssiRating(rssi).colorClass;
}
function rssiLabel(rssi: number | undefined): string {
if (rssi == null) return '—';
if (rssi >= -55) return 'sehr gut';
if (rssi >= -65) return 'gut';
if (rssi >= -75) return 'mäßig';
return 'schwach';
return rssiRating(rssi).label;
}
/** Aktuelles RSSI: letztes Sample der laufenden Session */

View file

@ -0,0 +1,380 @@
<script lang="ts">
/**
* WLAN-Kanalanalyse — Kanalgraph, Gruppierung nach SSID (Mesh), Kanal-
* empfehlung + Warnregeln, Momentaufnahmen fürs Vorher/Nachher beim
* Kanalwechsel sowie ein Zeitverlauf mehrerer Netze aus den bisherigen
* Momentaufnahmen dieses Protokolls.
*
* Anders als der WLAN-Empfangstracker (`/wifi/`, EIN Netz über Zeit beim
* Durchgehen) geht es hier um ALLE sichtbaren Netze auf einen Blick.
*/
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ChevronDown, RefreshCw, Camera, MapPin } from 'lucide-svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import WifiChannelChart from '$lib/components/WifiChannelChart.svelte';
import { getProtocol, saveProtocol } from '$lib/db';
import { addWifiSurvey, uid } from '$lib/protocols';
import { scanner, type WifiNetwork } from '$lib/scanner';
import { rssiRating } from '$lib/wifi/rating';
import { recommend24GhzChannels, channelWarnings } from '$lib/wifi/recommend';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { Protocol, WifiSurvey } from '$lib/types';
let protocol = $state<Protocol | null>(null);
let networks = $state<WifiNetwork[]>([]);
let connectedBssid = $state<string | null>(null);
let scanning = $state(false);
let locationError = $state(false);
let errorMsg = $state('');
let expandedSurvey = $state<string | null>(null);
const BANDS = ['2.4 GHz', '5 GHz', '6 GHz'] as const;
const bandsPresent = $derived.by(() => {
const set = new Set(networks.map((n) => n.band));
return BANDS.filter((b) => set.has(b));
});
/** Netze nach SSID gruppiert (Mesh: mehrere BSSIDs unter einer SSID) — eigenes/verbundenes Netz zuerst, sonst bestes Signal zuerst */
const groups = $derived.by(() => {
const bySsid = new Map<string, WifiNetwork[]>();
for (const n of networks) {
const key = n.ssid || `(versteckt) ${n.bssid}`;
const arr = bySsid.get(key);
if (arr) arr.push(n);
else bySsid.set(key, [n]);
}
const list = [...bySsid.entries()].map(([ssid, nets]) => ({
ssid,
nets: [...nets].sort((a, b) => b.rssi - a.rssi),
best: Math.max(...nets.map((n) => n.rssi)),
isConnected: nets.some((n) => n.bssid === connectedBssid),
}));
list.sort((a, b) => Number(b.isConnected) - Number(a.isConnected) || b.best - a.best);
return list;
});
const foreignNetworks = $derived(networks.filter((n) => n.bssid !== connectedBssid));
const recommendation = $derived(recommend24GhzChannels(foreignNetworks));
const warnings = $derived(channelWarnings(networks));
const pastSurveys = $derived([...(protocol?.wifiSurveys ?? [])].sort((a, b) => b.createdAt - a.createdAt));
const lastSurvey = $derived(pastSurveys[0]);
/** Kanaländerungen zwischen der letzten gespeicherten Momentaufnahme und dem aktuellen Live-Scan */
const changesSinceLastSurvey = $derived.by(() => {
if (!lastSurvey) return [];
const prevByBssid = new Map(lastSurvey.networks.map((n) => [n.bssid, n]));
const changes: string[] = [];
for (const n of networks) {
const prev = prevByBssid.get(n.bssid);
if (prev && prev.channel !== n.channel) {
changes.push(`„${n.ssid || n.bssid}": Kanal ${prev.channel} → ${n.channel}`);
}
}
return changes;
});
onMount(async () => {
const p = await getProtocol($page.params.id ?? '');
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
await refresh();
});
async function persist() {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
async function refresh() {
if (scanning) return;
scanning = true;
locationError = false;
errorMsg = '';
try {
const r = await scanner.wifiScan({ trigger: true });
networks = r.networks;
try {
const li = await scanner.linkInfo();
connectedBssid = li.links.find((l) => l.type === 'wifi')?.bssid ?? null;
} catch {
connectedBssid = null;
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'WLAN-Scan fehlgeschlagen';
errorMsg = msg;
locationError = msg.includes('Standort ist aus');
if (!locationError) toast.show(msg, 'error');
} finally {
scanning = false;
}
}
async function openLocationSettings() {
try {
await scanner.openLocationSettings();
} catch {
/* best effort — Nutzer kann auch von Hand navigieren */
}
}
async function saveSurvey() {
if (!protocol || networks.length === 0) return;
const survey: WifiSurvey = {
id: uid(),
createdAt: Date.now(),
networks: networks.map((n) => ({
ssid: n.ssid,
bssid: n.bssid,
channel: n.channel,
frequency: n.frequency,
band: n.band,
rssi: n.rssi,
widthMhz: n.widthMhz,
centerFreq0: n.centerFreq0,
centerFreq1: n.centerFreq1,
standard: n.standard,
security: n.security,
})),
};
addWifiSurvey(protocol, survey);
await persist();
toast.show('Momentaufnahme gespeichert', 'success');
}
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/* --- Zeitverlauf mehrerer APs aus den gespeicherten Momentaufnahmen --- */
const SERIES_COLORS = [
'text-sky-400',
'text-emerald-400',
'text-amber-400',
'text-fuchsia-400',
'text-red-400',
'text-lime-400',
];
const TL_W = 320;
const TL_H = 100;
const timeline = $derived.by(() => {
const surveys = [...(protocol?.wifiSurveys ?? [])].sort((a, b) => a.createdAt - b.createdAt);
if (surveys.length < 2) return null;
// Meistgesehene SSIDs zuerst, auf 6 begrenzt — mehr Linien wären nicht mehr lesbar
const counts = new Map<string, number>();
for (const s of surveys) {
for (const n of s.networks) {
const key = n.ssid || n.bssid;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
const topSsids = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([k]) => k);
const t0 = surveys[0].createdAt;
const t1 = surveys[surveys.length - 1].createdAt;
const span = Math.max(1, t1 - t0);
const x = (ts: number) => ((ts - t0) / span) * TL_W;
// Feste Skala -100..-20 dBm statt automatisch — sonst verschiebt sich die
// Achse zwischen zwei Momentaufnahmen und Werte sind nicht vergleichbar.
const y = (rssi: number) => TL_H - ((rssi + 100) / 80) * TL_H;
const series = topSsids.map((ssid, i) => {
const points = surveys.map((s) => {
const match = s.networks.find((n) => (n.ssid || n.bssid) === ssid);
return { ts: s.createdAt, rssi: match ? match.rssi : null };
});
// In Segmente teilen — eine Lücke (Netz in dieser Aufnahme nicht
// gesehen) wird NICHT durchgezogen, sonst täuscht das ein stabiles
// Signal vor, wo tatsächlich ein Aussetzer war.
const segments: string[] = [];
let current: string[] = [];
for (const p of points) {
if (p.rssi == null) {
if (current.length > 1) segments.push(current.join(' '));
current = [];
continue;
}
current.push(`${x(p.ts).toFixed(1)},${y(p.rssi).toFixed(1)}`);
}
if (current.length > 1) segments.push(current.join(' '));
return { ssid, segments, color: SERIES_COLORS[i % SERIES_COLORS.length] };
});
return { series, tFirst: t0, tLast: t1 };
});
</script>
{#if protocol}
<AppHeader title="WLAN-Kanalanalyse" subtitle={protocol.label} back />
<div class="flex-1 overflow-y-auto p-3">
{#if locationError}
<div class="rounded-lg bg-amber-900/40 p-3 text-sm text-amber-300">
<p>{errorMsg}</p>
<button
class="mt-2 flex items-center gap-1.5 rounded bg-amber-800/60 px-2.5 py-1.5 text-xs font-medium active:bg-amber-800"
onclick={openLocationSettings}
>
<MapPin size={14} /> Ortungseinstellungen öffnen
</button>
</div>
{:else if errorMsg}
<p class="text-sm text-red-400">{errorMsg}</p>
{/if}
<div class="mt-2 flex gap-2">
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-zinc-800 px-2 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
onclick={refresh}
disabled={scanning}
>
<RefreshCw size={14} class={scanning ? 'animate-spin' : ''} />
{scanning ? 'Scanne …' : 'Neu scannen'}
</button>
<button
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-sky-600 px-2 py-2 text-sm font-medium text-white active:bg-sky-700 disabled:opacity-50"
onclick={saveSurvey}
disabled={networks.length === 0}
>
<Camera size={14} /> Momentaufnahme speichern
</button>
</div>
{#if changesSinceLastSurvey.length > 0}
<div class="mt-2 rounded-lg bg-sky-900/30 p-2.5 text-xs text-sky-300">
<p class="font-medium">Seit der letzten Momentaufnahme ({fmtDateTime(lastSurvey.createdAt)}):</p>
{#each changesSinceLastSurvey as c (c)}
<p>{c}</p>
{/each}
</div>
{/if}
{#if warnings.length > 0}
<div class="mt-2 flex flex-col gap-1.5">
{#each warnings as w (w)}
<p class="rounded-lg bg-amber-900/30 p-2 text-xs text-amber-300">{w}</p>
{/each}
</div>
{/if}
{#if recommendation.length > 0}
<div class="mt-2 rounded-lg bg-zinc-900 p-2.5">
<p class="text-xs font-semibold text-zinc-300">Empfehlung 2,4-GHz-Kanal (weniger Störlast zuerst)</p>
<div class="mt-1 flex gap-3">
{#each recommendation as r, i (r.channel)}
<span class="text-sm {i === 0 ? 'font-semibold text-emerald-400' : 'text-zinc-400'}">
Kanal {r.channel} <span class="text-[10px]">({r.load})</span>
</span>
{/each}
</div>
</div>
{/if}
{#if bandsPresent.length > 0}
{#each bandsPresent as b (b)}
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
<p class="mb-1 text-xs font-semibold text-zinc-300">{b}</p>
<WifiChannelChart networks={networks.filter((n) => n.band === b)} band={b} highlightBssid={connectedBssid ?? undefined} />
</div>
{/each}
{:else if !scanning && !errorMsg}
<p class="mt-3 text-sm text-zinc-500">Keine Netze gefunden.</p>
{/if}
{#if timeline}
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
<p class="mb-1 text-xs font-semibold text-zinc-300">
Zeitverlauf ({fmtDateTime(timeline.tFirst)} {fmtDateTime(timeline.tLast)})
</p>
<svg viewBox="0 0 {TL_W} {TL_H}" class="w-full">
<line x1="0" y1={TL_H} x2={TL_W} y2={TL_H} stroke="currentColor" class="text-zinc-800" stroke-width="1" />
{#each timeline.series as s (s.ssid)}
{#each s.segments as seg, i (i)}
<polyline points={seg} fill="none" stroke="currentColor" stroke-width="1.5" class={s.color} />
{/each}
{/each}
</svg>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1">
{#each timeline.series as s (s.ssid)}
<span class="flex items-center gap-1 text-[10px] {s.color}">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{s.ssid}
</span>
{/each}
</div>
</div>
{/if}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Netze ({groups.length})</h2>
<div class="flex flex-col gap-1.5">
{#each groups as g (g.ssid)}
<div class="rounded-lg bg-zinc-900 p-2.5">
<div class="flex items-center justify-between">
<span class="truncate text-sm font-medium">{g.ssid}</span>
{#if g.isConnected}
<span class="shrink-0 text-[10px] text-emerald-400">verbunden</span>
{/if}
</div>
{#each g.nets as n (n.bssid)}
{@const rating = rssiRating(n.rssi)}
<div class="mt-1 flex items-center justify-between text-[11px] text-zinc-500">
<span>
Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard
? ` · ${n.standard}`
: ''}{n.security ? ` · ${n.security}` : ''}
</span>
<span class="shrink-0 {rating.colorClass} tabular-nums">{n.rssi} dBm</span>
</div>
{/each}
</div>
{/each}
</div>
{#if pastSurveys.length > 0}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Momentaufnahmen</h2>
{#each pastSurveys as s (s.id)}
<div class="mb-1.5 rounded-lg bg-zinc-900">
<button
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
onclick={() => (expandedSurvey = expandedSurvey === s.id ? null : s.id)}
>
<span class="text-sm">{s.networks.length} Netze · {fmtDateTime(s.createdAt)}</span>
<ChevronDown size={16} class="shrink-0 text-zinc-500 {expandedSurvey === s.id ? 'rotate-180' : ''}" />
</button>
{#if expandedSurvey === s.id}
<div class="border-t border-zinc-800 p-2.5 text-xs">
{#each s.networks.sort((a, b) => b.rssi - a.rssi) as n (n.bssid)}
<div class="flex items-center justify-between py-0.5">
<span class="text-zinc-400">{n.ssid || n.bssid} · Kanal {n.channel}</span>
<span class="tabular-nums {rssiRating(n.rssi).colorClass}">{n.rssi} dBm</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}