netdiag-app/src/routes/protokoll/[id]/wifi/+page.svelte
Eduard Wisch 3c95ff6b07
All checks were successful
Build APK / build-apk (push) Successful in 1m49s
Phase 6: IP-Test (Dose prüfen) und WLAN-Empfangstracker [apk]
IP-Test: USB-RJ45-Adapter in Netzwerkdose stecken und sofort IP-Adresse,
DHCP-Server, Gateway und Link-Geschwindigkeit (10/100/1000 Mbit) ablesen.
Auto-Refresh alle 2 s, Speichern mit optionalem Raum/Dose-Name ins Protokoll.

WLAN-Empfangstracker: Netz auswählen und beim Durchgehen live RSSI verfolgen.
Hybrid-Modus: 500 ms Polling bei verbundenem Netz (kein Scan-Throttling),
~30 s Scan-Sweep bei Fremd-BSSID. Sessions mit Samples, Min/Max/Avg und
Sparkline-Verlauf werden im Protokoll gespeichert.

Ersetzt DHCP-Info-Tool und WLAN-Scan-Tool (eigene Routen /iptest/ + /wifi/).
Kotlin-Plugin: linkInfo(), startWifiScan(), startWifiTrack/stop/status().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:03:25 +02:00

412 lines
14 KiB
Svelte

<script lang="ts">
/**
* WLAN-Empfangstracker — listet WLAN-Netze, beim Klick auf ein Netz wechselt
* die Seite in den Tracker-Modus: laufende RSSI-Anzeige beim Durchlaufen
* durchs Gebäude. Beim verbundenen Netz live (alle 500 ms), bei Fremd-BSSIDs
* scan-basiert (~30 s pro Sample, Android-9+-Limit).
*
* Stop friert eine `WifiTrackSession` ein und legt sie ins Protokoll.
*/
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ChevronDown, RefreshCw, Wifi } from 'lucide-svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import { getProtocol, saveProtocol } from '$lib/db';
import { addWifiTrackSession, uid } from '$lib/protocols';
import {
scanner,
onWifiSignal,
type WifiNetwork,
type WifiSignalEvent,
} from '$lib/scanner';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types';
let protocol = $state<Protocol | null>(null);
let networks = $state<WifiNetwork[]>([]);
let connectedBssid = $state<string | null>(null);
let busy = $state(false);
let scanning = $state(false);
let throttled = $state(false);
let expanded = $state<string | null>(null);
// Aktive Tracker-Session (null wenn keine läuft)
let session = $state<WifiTrackSession | null>(null);
let offSignal: (() => void) | undefined;
const running = $derived(session?.status === 'running');
const pastSessions = $derived(
(protocol?.wifiTrackSessions ?? []).filter((s) => s.status === 'stopped'),
);
onMount(async () => {
const p = await getProtocol($page.params.id ?? '');
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
// Läuft bereits ein Tracker? → wieder andocken
const live = p.wifiTrackSessions?.find((s) => s.status === 'running');
if (live?.runId) {
try {
const st = await scanner.getWifiTrackStatus({ runId: live.runId });
if (st.running) {
live.samples = st.samples;
live.mode = st.mode;
session = live;
attachSignalListener(live.runId);
return;
}
live.status = 'stopped';
live.endedAt = Date.now();
await persist();
} catch {
live.status = 'stopped';
await persist();
}
}
await refreshNetworks();
});
onDestroy(() => {
// Tracker läuft im Plugin (oder im Mock) weiter — nur den Listener lösen
offSignal?.();
void persist();
});
async function persist(): Promise<void> {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
async function refreshNetworks() {
if (scanning) return;
scanning = true;
throttled = false;
try {
const triggered = await scanner.startWifiScan().catch(() => ({ triggered: true }));
if (!triggered.triggered) throttled = true;
// Kurz warten, dann den frisch gefüllten Cache lesen (im Mock egal)
await new Promise((r) => setTimeout(r, 800));
const r = await scanner.wifiScan();
networks = [...r.networks].sort((a, b) => b.rssi - a.rssi);
// Aktuell verbundenes Netz aus linkInfo holen
try {
const li = await scanner.linkInfo();
connectedBssid = li.links.find((l) => l.type === 'wifi')?.bssid ?? null;
} catch {
connectedBssid = null;
}
} catch (e) {
toast.show(e instanceof Error ? e.message : 'WLAN-Scan fehlgeschlagen', 'error');
} finally {
scanning = false;
}
}
function attachSignalListener(runId: string) {
offSignal?.();
offSignal = onWifiSignal((e: WifiSignalEvent) => {
if (e.runId !== runId || !session) return;
const s: WifiSignalSample = { ts: e.ts, rssi: e.rssi, source: e.source };
session.samples.push(s);
// Statistik nachziehen
const vals = session.samples.map((x) => x.rssi);
session.min = Math.min(...vals);
session.max = Math.max(...vals);
session.avg = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
void persist();
});
}
async function startTrack(n: WifiNetwork) {
if (!protocol || busy) return;
busy = true;
try {
const { runId, mode } = await scanner.startWifiTrack({
bssid: n.bssid,
intervalMs: 500,
});
const newSession: WifiTrackSession = {
id: uid(),
name: n.ssid || n.bssid,
startedAt: Date.now(),
bssid: n.bssid,
ssid: n.ssid,
band: n.band,
channel: n.channel,
samples: [],
status: 'running',
runId,
mode,
};
addWifiTrackSession(protocol, newSession);
// Den frisch gepushten Eintrag aus dem Array holen (Svelte-Proxy → Reaktivität)
session = protocol.wifiTrackSessions![protocol.wifiTrackSessions!.length - 1];
attachSignalListener(runId);
await persist();
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Tracker-Start fehlgeschlagen', 'error');
} finally {
busy = false;
}
}
async function stopTrack() {
if (!session?.runId || busy) return;
busy = true;
try {
const res = await scanner.stopWifiTrack({ runId: session.runId });
session.samples = res.samples;
session.min = res.min;
session.max = res.max;
session.avg = res.avg;
session.status = 'stopped';
session.endedAt = Date.now();
offSignal?.();
offSignal = undefined;
await persist();
toast.show('Aufzeichnung gespeichert', 'success');
session = null;
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Tracker-Stopp fehlgeschlagen', 'error');
} finally {
busy = false;
}
}
/* --- Anzeige-Helfer --- */
function rssiColor(rssi: number | undefined): string {
if (rssi == null) return 'text-zinc-500';
if (rssi >= -55) return 'text-emerald-400';
if (rssi >= -70) return 'text-amber-400';
return 'text-red-400';
}
function rssiLabel(rssi: number | undefined): string {
if (rssi == null) return '—';
if (rssi >= -55) return 'sehr gut';
if (rssi >= -65) return 'gut';
if (rssi >= -75) return 'mäßig';
return 'schwach';
}
/** Aktuelles RSSI: letztes Sample der laufenden Session */
const currentRssi = $derived(
session && session.samples.length > 0
? session.samples[session.samples.length - 1].rssi
: undefined,
);
/** Letzte 60 Samples für die Sparkline */
const sparklinePoints = $derived.by(() => {
if (!session || session.samples.length === 0) return '';
const tail = session.samples.slice(-60);
const min = Math.min(-85, ...tail.map((s) => s.rssi));
const max = Math.max(-40, ...tail.map((s) => s.rssi));
const w = 280;
const h = 50;
return tail
.map((s, i) => {
const x = (i / Math.max(1, tail.length - 1)) * w;
const y = h - ((s.rssi - min) / Math.max(1, max - min)) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
function fmtTime(ts: number): string {
return new Date(ts).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
{#if protocol}
<AppHeader
title={running ? 'Empfangstracker' : 'WLAN-Empfangstracker'}
subtitle={running ? (session?.ssid ?? '') : protocol.label}
back
/>
<div class="flex-1 overflow-y-auto p-3">
{#if running && session}
<!-- Tracker-Detail-Ansicht -->
<div class="rounded-lg bg-zinc-900 p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-zinc-500">
{session.mode === 'connected'
? 'Live (verbundenes Netz, 0,5 s)'
: 'Scan-Modus (~30 s · Android-Limit)'}
</span>
<span class="text-xs text-zinc-500">Kanal {session.channel} · {session.band}</span>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-5xl font-bold {rssiColor(currentRssi)} tabular-nums">
{currentRssi ?? '—'}
</span>
<span class="text-lg text-zinc-400">dBm</span>
<span class="text-sm {rssiColor(currentRssi)}">{rssiLabel(currentRssi)}</span>
</div>
<!-- Sparkline der letzten 60 Samples -->
{#if sparklinePoints}
<svg viewBox="0 0 280 50" class="mt-2 w-full" preserveAspectRatio="none">
<polyline
points={sparklinePoints}
fill="none"
stroke="currentColor"
stroke-width="1.5"
class={rssiColor(currentRssi)}
/>
</svg>
{/if}
<div class="mt-2 grid grid-cols-3 gap-2 text-xs">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Min</div>
<div class={rssiColor(session.min)}>{session.min ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ø</div>
<div class={rssiColor(session.avg)}>{session.avg ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Max</div>
<div class={rssiColor(session.max)}>{session.max ?? '—'} dBm</div>
</div>
</div>
<p class="mt-2 text-[11px] text-zinc-500">
{session.samples.length} Samples · seit {fmtTime(session.startedAt)}
</p>
<button
class="mt-3 w-full rounded-lg bg-red-600 py-2 text-sm font-semibold text-white active:bg-red-700 disabled:opacity-50"
onclick={stopTrack}
disabled={busy}
>
Aufzeichnung beenden
</button>
</div>
{:else}
<!-- Listen-Ansicht (Netzwahl) -->
<div class="flex items-center gap-2">
<h2 class="flex-1 text-sm font-semibold text-zinc-300">WLAN auswählen</h2>
<button
class="flex items-center gap-1 rounded bg-zinc-800 px-2 py-1.5 text-xs text-zinc-300 active:bg-zinc-700 disabled:opacity-50"
onclick={refreshNetworks}
disabled={scanning}
>
<RefreshCw size={14} class={scanning ? 'animate-spin' : ''} />
Scan
</button>
</div>
{#if throttled}
<p class="mt-1 text-[11px] leading-tight text-amber-400">
Android-Scan-Throttling — letztes Cache-Ergebnis wird gezeigt. In 2 Min nochmal.
</p>
{/if}
{#if networks.length === 0}
<p class="mt-3 text-sm text-zinc-500">Noch keine Netze — bitte Scan starten.</p>
{:else}
<div class="mt-2 flex flex-col gap-1">
{#each networks as n (n.bssid)}
{@const isConnected = n.bssid === connectedBssid}
<button
class="flex items-center gap-2 rounded-lg bg-zinc-800 px-2.5 py-2 text-left active:bg-zinc-700 disabled:opacity-50"
onclick={() => startTrack(n)}
disabled={busy}
>
<Wifi size={16} class={rssiColor(n.rssi)} />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{n.ssid}</div>
<div class="text-[11px] text-zinc-500">
Kanal {n.channel} · {n.band}
{#if isConnected}
· <span class="text-emerald-400">verbunden — Live-Tracking</span>
{/if}
</div>
</div>
<span class="shrink-0 text-xs {rssiColor(n.rssi)} tabular-nums">{n.rssi} dBm</span>
</button>
{/each}
</div>
<p class="mt-2 text-[11px] leading-tight text-zinc-500">
Tippe ein Netz an — der Tracker zeigt das Signal live, während du durchs Gebäude
gehst. Verbundenes Netz: alle 0,5 s. Fremdes Netz: alle ~30 s (Android-Limit).
</p>
{/if}
{/if}
<!-- Frühere Tracker-Sessions -->
{#if !running && pastSessions.length > 0}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Aufzeichnungen</h2>
{#each pastSessions 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={() => (expanded = expanded === s.id ? null : s.id)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{s.name}</div>
<div class="text-[11px] text-zinc-500">
{s.samples.length} Samples · Ø {s.avg ?? '—'} dBm ·
{fmtDateTime(s.startedAt)}
</div>
</div>
<ChevronDown
size={16}
class="shrink-0 text-zinc-500 {expanded === s.id ? 'rotate-180' : ''}"
/>
</button>
{#if expanded === s.id}
<div class="border-t border-zinc-800 p-2.5 text-xs">
<div class="grid grid-cols-3 gap-2">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Min</div>
<div class={rssiColor(s.min)}>{s.min ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ø</div>
<div class={rssiColor(s.avg)}>{s.avg ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Max</div>
<div class={rssiColor(s.max)}>{s.max ?? '—'} dBm</div>
</div>
</div>
<p class="mt-1 text-[11px] text-zinc-500">
BSSID {s.bssid} · Kanal {s.channel} · {s.band}
</p>
</div>
{/if}
</div>
{/each}
{/if}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}