All checks were successful
Build APK / build-apk (push) Successful in 1m49s
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>
412 lines
14 KiB
Svelte
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}
|