netdiag-app/src/routes/protokoll/[id]/wifikanal/+page.svelte
Eduard Wisch 39a0507f99 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
2026-08-15 16:16:15 +02:00

380 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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}