Compare commits
No commits in common. "28cd7705371a23d3224bd8eb478d46edf6190bdc" and "7fdf693f36d386e60fe576d9e5a449af4f7a6d4b" have entirely different histories.
28cd770537
...
7fdf693f36
13 changed files with 46 additions and 960 deletions
|
|
@ -1,6 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
@ -51,16 +50,6 @@
|
|||
<!-- 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
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
|
@ -75,11 +74,7 @@ import java.util.concurrent.TimeUnit
|
|||
@CapacitorPlugin(
|
||||
name = "NetDiagScanner",
|
||||
permissions = [
|
||||
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"]),
|
||||
Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
]
|
||||
)
|
||||
class NetDiagScannerPlugin : Plugin() {
|
||||
|
|
@ -972,64 +967,10 @@ 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 (!hasWifiScanPermission()) {
|
||||
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiScanPermCallback")
|
||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
||||
requestPermissionForAlias("location", call, "wifiScanPermCallback")
|
||||
return
|
||||
}
|
||||
doWifiScan(call)
|
||||
|
|
@ -1037,70 +978,29 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
|
||||
@PermissionCallback
|
||||
private fun wifiScanPermCallback(call: PluginCall) {
|
||||
if (hasWifiScanPermission()) {
|
||||
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
|
||||
doWifiScan(call)
|
||||
} else {
|
||||
call.reject("Berechtigung für WLAN-Scan abgelehnt")
|
||||
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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}")
|
||||
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"))
|
||||
}
|
||||
resolve(call, JSObject().put("networks", arr))
|
||||
} catch (e: Exception) {
|
||||
call.reject("wifiScan: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1897,8 +1797,8 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
*/
|
||||
@PluginMethod
|
||||
fun startWifiScan(call: PluginCall) {
|
||||
if (!hasWifiScanPermission()) {
|
||||
requestPermissionForAlias(wifiScanPermissionAlias(), call, "startWifiScanPermCallback")
|
||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
||||
requestPermissionForAlias("location", call, "startWifiScanPermCallback")
|
||||
return
|
||||
}
|
||||
doStartWifiScan(call)
|
||||
|
|
@ -1906,10 +1806,10 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
|
||||
@PermissionCallback
|
||||
private fun startWifiScanPermCallback(call: PluginCall) {
|
||||
if (hasWifiScanPermission()) {
|
||||
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
|
||||
doStartWifiScan(call)
|
||||
} else {
|
||||
call.reject("Berechtigung für WLAN-Scan abgelehnt")
|
||||
call.reject("Standortberechtigung für WLAN-Scan abgelehnt")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1935,8 +1835,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 (!hasWifiScanPermission()) {
|
||||
requestPermissionForAlias(wifiScanPermissionAlias(), call, "wifiTrackPermCallback")
|
||||
if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) {
|
||||
requestPermissionForAlias("location", call, "wifiTrackPermCallback")
|
||||
return
|
||||
}
|
||||
doStartWifiTrack(call, bssid, intervalMs)
|
||||
|
|
@ -1944,12 +1844,12 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
|
||||
@PermissionCallback
|
||||
private fun wifiTrackPermCallback(call: PluginCall) {
|
||||
if (hasWifiScanPermission()) {
|
||||
if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) {
|
||||
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("Berechtigung für WLAN-Tracker abgelehnt")
|
||||
call.reject("Standortberechtigung für WLAN-Tracker abgelehnt")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2313,51 +2213,9 @@ 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"
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
<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,7 +29,6 @@ function normalizeProtocol(p: Protocol): Protocol {
|
|||
p.savedScans ??= [];
|
||||
p.monitorSessions ??= [];
|
||||
p.wifiTrackSessions ??= [];
|
||||
p.wifiSurveys ??= [];
|
||||
p.stressTestSessions ??= [];
|
||||
return p;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type {
|
|||
Measurement,
|
||||
Protocol,
|
||||
SavedScan,
|
||||
WifiSurvey,
|
||||
WifiTrackSession,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -122,9 +121,3 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,17 +165,6 @@ 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) */
|
||||
|
|
@ -243,18 +232,8 @@ 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. 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 }>;
|
||||
/** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */
|
||||
wifiScan(): Promise<{ networks: WifiNetwork[] }>;
|
||||
/** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
|
||||
dhcpInfo(): Promise<DhcpLease>;
|
||||
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
||||
|
|
@ -569,81 +548,14 @@ 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,
|
||||
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,
|
||||
},
|
||||
{ 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' },
|
||||
],
|
||||
timedOut: false,
|
||||
};
|
||||
},
|
||||
async openLocationSettings() {
|
||||
return { ok: true };
|
||||
},
|
||||
async dhcpInfo() {
|
||||
return {
|
||||
server: '192.168.1.1',
|
||||
|
|
|
|||
|
|
@ -190,36 +190,6 @@ 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
|
||||
|
|
@ -297,8 +267,6 @@ 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 */
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* 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,17 +360,6 @@
|
|||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
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';
|
||||
|
|
@ -86,7 +85,10 @@
|
|||
}
|
||||
|
||||
function rssiColor(rssi?: number): string {
|
||||
return rssiRating(rssi).colorClass;
|
||||
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';
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
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';
|
||||
|
|
@ -186,14 +185,19 @@
|
|||
|
||||
/* --- 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 {
|
||||
return rssiRating(rssi).colorClass;
|
||||
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';
|
||||
}
|
||||
|
||||
function rssiLabel(rssi: number | undefined): string {
|
||||
return rssiRating(rssi).label;
|
||||
if (rssi == null) return '—';
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
<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