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
380 lines
14 KiB
Svelte
380 lines
14 KiB
Svelte
<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}
|