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:
parent
7fdf693f36
commit
39a0507f99
13 changed files with 960 additions and 46 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|
@ -50,6 +51,16 @@
|
||||||
<!-- Multicast-Lock für die mDNS-/Bonjour-Dienstsuche (NsdManager) -->
|
<!-- 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.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<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" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<!--
|
<!--
|
||||||
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den
|
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.location.LocationManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.LinkProperties
|
import android.net.LinkProperties
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
|
|
@ -74,7 +75,11 @@ import java.util.concurrent.TimeUnit
|
||||||
@CapacitorPlugin(
|
@CapacitorPlugin(
|
||||||
name = "NetDiagScanner",
|
name = "NetDiagScanner",
|
||||||
permissions = [
|
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() {
|
class NetDiagScannerPlugin : Plugin() {
|
||||||
|
|
@ -967,10 +972,64 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
/* WLAN-Scan */
|
/* 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
|
@PluginMethod
|
||||||
fun wifiScan(call: PluginCall) {
|
fun wifiScan(call: PluginCall) {
|
||||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
if (!hasWifiScanPermission()) {
|
||||||
requestPermissionForAlias("location", call, "wifiScanPermCallback")
|
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiScanPermCallback")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doWifiScan(call)
|
doWifiScan(call)
|
||||||
|
|
@ -978,31 +1037,72 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
|
|
||||||
@PermissionCallback
|
@PermissionCallback
|
||||||
private fun wifiScanPermCallback(call: PluginCall) {
|
private fun wifiScanPermCallback(call: PluginCall) {
|
||||||
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
|
if (hasWifiScanPermission()) {
|
||||||
doWifiScan(call)
|
doWifiScan(call)
|
||||||
} else {
|
} else {
|
||||||
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
|
call.reject("Berechtigung für WLAN-Scan abgelehnt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun doWifiScan(call: PluginCall) {
|
private fun doWifiScan(call: PluginCall) {
|
||||||
|
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 {
|
try {
|
||||||
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
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 arr = JSArray()
|
||||||
|
val nowElapsedUs = android.os.SystemClock.elapsedRealtime() * 1000L
|
||||||
for (r in wifi.scanResults) {
|
for (r in wifi.scanResults) {
|
||||||
val freq = r.frequency
|
val freq = r.frequency
|
||||||
arr.put(JSObject()
|
val ageMs = ((nowElapsedUs - r.timestamp) / 1000L).coerceAtLeast(0L)
|
||||||
|
val dev = JSObject()
|
||||||
.put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID)
|
.put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID)
|
||||||
.put("bssid", r.BSSID ?: "")
|
.put("bssid", r.BSSID ?: "")
|
||||||
.put("channel", freqToChannel(freq))
|
.put("channel", freqToChannel(freq))
|
||||||
|
.put("frequency", freq)
|
||||||
.put("rssi", r.level)
|
.put("rssi", r.level)
|
||||||
.put("band", if (freq > 4000) "5 GHz" else "2.4 GHz"))
|
.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)
|
||||||
}
|
}
|
||||||
resolve(call, JSObject().put("networks", arr))
|
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) {
|
} catch (e: Exception) {
|
||||||
call.reject("wifiScan: ${e.message}")
|
call.reject("wifiScan: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
/* DHCP-Info — DHCP-Server, von dem das Gerät seine Adresse bezieht */
|
/* DHCP-Info — DHCP-Server, von dem das Gerät seine Adresse bezieht */
|
||||||
|
|
@ -1797,8 +1897,8 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
*/
|
*/
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun startWifiScan(call: PluginCall) {
|
fun startWifiScan(call: PluginCall) {
|
||||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
if (!hasWifiScanPermission()) {
|
||||||
requestPermissionForAlias("location", call, "startWifiScanPermCallback")
|
requestPermissionForAlias(wifiScanPermissionAlias(), call, "startWifiScanPermCallback")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doStartWifiScan(call)
|
doStartWifiScan(call)
|
||||||
|
|
@ -1806,10 +1906,10 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
|
|
||||||
@PermissionCallback
|
@PermissionCallback
|
||||||
private fun startWifiScanPermCallback(call: PluginCall) {
|
private fun startWifiScanPermCallback(call: PluginCall) {
|
||||||
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
|
if (hasWifiScanPermission()) {
|
||||||
doStartWifiScan(call)
|
doStartWifiScan(call)
|
||||||
} else {
|
} 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) {
|
fun startWifiTrack(call: PluginCall) {
|
||||||
val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt")
|
val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt")
|
||||||
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
|
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
|
||||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
if (!hasWifiScanPermission()) {
|
||||||
requestPermissionForAlias("location", call, "wifiTrackPermCallback")
|
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiTrackPermCallback")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doStartWifiTrack(call, bssid, intervalMs)
|
doStartWifiTrack(call, bssid, intervalMs)
|
||||||
|
|
@ -1844,12 +1944,12 @@ class NetDiagScannerPlugin : Plugin() {
|
||||||
|
|
||||||
@PermissionCallback
|
@PermissionCallback
|
||||||
private fun wifiTrackPermCallback(call: PluginCall) {
|
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 bssid = call.getString("bssid") ?: return call.reject("bssid fehlt")
|
||||||
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
|
val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000)
|
||||||
doStartWifiTrack(call, bssid, intervalMs)
|
doStartWifiTrack(call, bssid, intervalMs)
|
||||||
} else {
|
} 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 == 2484 -> 14
|
||||||
freq in 2412..2472 -> (freq - 2412) / 5 + 1
|
freq in 2412..2472 -> (freq - 2412) / 5 + 1
|
||||||
freq in 5170..5825 -> (freq - 5170) / 5 + 34
|
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
|
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) {
|
private fun serviceName(port: Int): String = when (port) {
|
||||||
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
|
21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http"
|
||||||
139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"
|
139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus"
|
||||||
|
|
|
||||||
128
src/lib/components/WifiChannelChart.svelte
Normal file
128
src/lib/components/WifiChannelChart.svelte
Normal 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}
|
||||||
|
|
@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol {
|
||||||
p.savedScans ??= [];
|
p.savedScans ??= [];
|
||||||
p.monitorSessions ??= [];
|
p.monitorSessions ??= [];
|
||||||
p.wifiTrackSessions ??= [];
|
p.wifiTrackSessions ??= [];
|
||||||
|
p.wifiSurveys ??= [];
|
||||||
p.stressTestSessions ??= [];
|
p.stressTestSessions ??= [];
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
Measurement,
|
Measurement,
|
||||||
Protocol,
|
Protocol,
|
||||||
SavedScan,
|
SavedScan,
|
||||||
|
WifiSurvey,
|
||||||
WifiTrackSession,
|
WifiTrackSession,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
|
@ -121,3 +122,9 @@ export function addWifiTrackSession(
|
||||||
(protocol.wifiTrackSessions ??= []).push(s);
|
(protocol.wifiTrackSessions ??= []).push(s);
|
||||||
return 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,17 @@ export interface WifiNetwork {
|
||||||
channel: number;
|
channel: number;
|
||||||
rssi: number;
|
rssi: number;
|
||||||
band: string;
|
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 {
|
export interface DhcpLease {
|
||||||
/** DHCP-Server, von dem das Gerät seine IP bezieht ('' wenn unbekannt) */
|
/** 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[] }>;
|
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
|
||||||
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
|
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
|
||||||
pingQuality(opts: { host: string; count: number }): Promise<PingQuality>;
|
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) */
|
/** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
|
||||||
dhcpInfo(): Promise<DhcpLease>;
|
dhcpInfo(): Promise<DhcpLease>;
|
||||||
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
||||||
|
|
@ -548,14 +569,81 @@ const mock: NetDiagScannerPlugin = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async wifiScan() {
|
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 {
|
return {
|
||||||
networks: [
|
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',
|
||||||
{ ssid: 'AllesWattLaeuft-5G', bssid: 'AA:BB:CC:11:22:34', channel: 36, rssi: -58, band: '5 GHz' },
|
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() {
|
async dhcpInfo() {
|
||||||
return {
|
return {
|
||||||
server: '192.168.1.1',
|
server: '192.168.1.1',
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,36 @@ export interface StressBucket {
|
||||||
lossPct: number;
|
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
|
* Laufende oder gerade beendete Dauer-/Stresstest-Sitzung — NUR LOKAL
|
||||||
* (nicht synchronisiert, wie DeviceMonitorSession/WifiTrackSession). Dient der
|
* (nicht synchronisiert, wie DeviceMonitorSession/WifiTrackSession). Dient der
|
||||||
|
|
@ -267,6 +297,8 @@ export interface Protocol {
|
||||||
monitorSessions?: DeviceMonitorSession[];
|
monitorSessions?: DeviceMonitorSession[];
|
||||||
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
|
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
|
||||||
wifiTrackSessions?: WifiTrackSession[];
|
wifiTrackSessions?: WifiTrackSession[];
|
||||||
|
/** WLAN-Kanal-Momentaufnahmen (nur lokal, wird nicht synchronisiert) */
|
||||||
|
wifiSurveys?: WifiSurvey[];
|
||||||
/** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */
|
/** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */
|
||||||
stressTestSessions?: StressTestSession[];
|
stressTestSessions?: StressTestSession[];
|
||||||
/** true solange noch nicht zum Server synchronisiert */
|
/** true solange noch nicht zum Server synchronisiert */
|
||||||
|
|
|
||||||
31
src/lib/wifi/rating.ts
Normal file
31
src/lib/wifi/rating.ts
Normal 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
89
src/lib/wifi/recommend.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -360,6 +360,17 @@
|
||||||
Empfangsstärke beim Durchgehen aufzeichnen.
|
Empfangsstärke beim Durchgehen aufzeichnen.
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
import { getProtocol, saveProtocol } from '$lib/db';
|
import { getProtocol, saveProtocol } from '$lib/db';
|
||||||
import { addMeasurement } from '$lib/protocols';
|
import { addMeasurement } from '$lib/protocols';
|
||||||
import { scanner } from '$lib/scanner';
|
import { scanner } from '$lib/scanner';
|
||||||
|
import { rssiRating } from '$lib/wifi/rating';
|
||||||
import { sync } from '$lib/sync.svelte';
|
import { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import type { LinkInfo, Protocol } from '$lib/types';
|
import type { LinkInfo, Protocol } from '$lib/types';
|
||||||
|
|
@ -85,10 +86,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function rssiColor(rssi?: number): string {
|
function rssiColor(rssi?: number): string {
|
||||||
if (rssi == null) return 'text-zinc-500';
|
return rssiRating(rssi).colorClass;
|
||||||
if (rssi >= -55) return 'text-emerald-400';
|
|
||||||
if (rssi >= -70) return 'text-amber-400';
|
|
||||||
return 'text-red-400';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persist(): Promise<void> {
|
async function persist(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
type WifiNetwork,
|
type WifiNetwork,
|
||||||
type WifiSignalEvent,
|
type WifiSignalEvent,
|
||||||
} from '$lib/scanner';
|
} from '$lib/scanner';
|
||||||
|
import { rssiRating } from '$lib/wifi/rating';
|
||||||
import { sync } from '$lib/sync.svelte';
|
import { sync } from '$lib/sync.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types';
|
import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types';
|
||||||
|
|
@ -185,19 +186,14 @@
|
||||||
|
|
||||||
/* --- Anzeige-Helfer --- */
|
/* --- 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 {
|
function rssiColor(rssi: number | undefined): string {
|
||||||
if (rssi == null) return 'text-zinc-500';
|
return rssiRating(rssi).colorClass;
|
||||||
if (rssi >= -55) return 'text-emerald-400';
|
|
||||||
if (rssi >= -70) return 'text-amber-400';
|
|
||||||
return 'text-red-400';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rssiLabel(rssi: number | undefined): string {
|
function rssiLabel(rssi: number | undefined): string {
|
||||||
if (rssi == null) return '—';
|
return rssiRating(rssi).label;
|
||||||
if (rssi >= -55) return 'sehr gut';
|
|
||||||
if (rssi >= -65) return 'gut';
|
|
||||||
if (rssi >= -75) return 'mäßig';
|
|
||||||
return 'schwach';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aktuelles RSSI: letztes Sample der laufenden Session */
|
/** Aktuelles RSSI: letztes Sample der laufenden Session */
|
||||||
|
|
|
||||||
380
src/routes/protokoll/[id]/wifikanal/+page.svelte
Normal file
380
src/routes/protokoll/[id]/wifikanal/+page.svelte
Normal 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}
|
||||||
Loading…
Reference in a new issue