netdiag-app/src/routes/protokoll/[id]/wifikanal/+page.svelte
Eduard Wisch 2d35c6b729 WLAN-Demodaten: Kanalanalyse ohne echten Empfang ansehen
Auf Eddys Vorschlag. Der Android-Emulator hat keine WLAN-Hardware und liefert
bei jedem Scan null Netze — Kanalgraph, Gruppierung, Kanalempfehlung und die
Warnregeln ließen sich damit auf dem Gerät überhaupt nicht ansehen. Genau
deshalb ging die erste Fassung der Diagramme unbemerkt unlesbar in den Einsatz.

Schalter in den Einstellungen (Diagnose → WLAN-Demodaten). Ist er an, zeigt die
Kanalanalyse eine realistische Beispielumgebung statt echter Messwerte. Die
Daten decken bewusst die Grenzfälle ab: Mesh über zwei Bänder, mehrere Netze
auf demselben Kanal, ein 40-MHz-Netz im 2,4-GHz-Band und ein DFS- sowie ein
160-MHz-Netz im 5-GHz-Band (lösen beide Warnregeln aus), Netze in allen drei
UNII-Blöcken, ein Netz am Bandrand, ein offenes Netz ohne Standard-Angabe.
Leichtes Rauschen je Scan, damit mehrere Momentaufnahmen einen Zeitverlauf mit
sichtbarem Auf und Ab ergeben.

Damit keine Demo je für eine echte Messung gehalten wird:
- deutlich sichtbarer Hinweis im Kopf der Seite, solange der Modus an ist
- Messungs-Label beginnt mit "DEMODATEN — "
- result-JSON trägt demodaten:true; Dolibarr zeigt das in der Kartenansicht und
  im PDF als eigene Zeile (PDF besonders wichtig, das Dokument geht zum Kunden)

Im Emulator durchgespielt: Schalter an -> Kanalanalyse zeigt alle Diagramme mit
Netzen, 5-GHz-Band in drei lesbaren UNII-Blöcken, Fazit-Zeile und alle drei
Warnhinweise korrekt. Momentaufnahme gespeichert, synchronisiert und in der
Test-DB geprüft: Label und result-Flag korrekt als Demo markiert.

Zweitnutzen: Vorführung beim Kunden, unabhängig davon was gerade in der Luft ist.
2026-08-15 17:45:52 +02:00

417 lines
16 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 WifiTimelineChart from '$lib/components/WifiTimelineChart.svelte';
import { getProtocol, saveProtocol } from '$lib/db';
import { addWifiSurvey, addMeasurement, 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 { netColor } from '$lib/wifi/chart';
import { demoNetworks, ladeDemoSchalter, DEMO_CONNECTED_BSSID } from '$lib/wifi/demo';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { MeasureStatus, 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);
let demoAktiv = $state(false);
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;
demoAktiv = await ladeDemoSchalter();
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 {
// Demomodus: der Emulator hat keine WLAN-Hardware und liefert immer 0
// Netze — damit ließe sich die gesamte Anzeige nicht prüfen. Der
// Schalter steht in den Einstellungen und ist im Kopf der Seite deutlich
// sichtbar, damit keine Demomessung versehentlich als echte Messung im
// Kundenprotokoll landet.
if (demoAktiv) {
networks = demoNetworks();
connectedBssid = DEMO_CONNECTED_BSSID;
return;
}
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);
// Zusätzlich als echte Messung ablegen. `wifiSurveys` allein bleibt nur
// lokal auf dem Gerät — eine beim Kunden aufgenommene Momentaufnahme kam
// damit nie im Protokoll auf dem Server an, obwohl sie genau dafür
// gemacht wird. Als Messung landet sie in der Synchronisation und im PDF.
const eigen = survey.networks.filter((n) => n.bssid === connectedBssid);
const eigenStk = eigen.sort((a, b) => b.rssi - a.rssi)[0];
const warnungen = channelWarnings(networks);
const status: MeasureStatus = eigenStk
? rssiRating(eigenStk.rssi).status
: warnungen.length > 0
? 1
: 0;
addMeasurement(protocol, {
deviceClientId: null,
tool: 'wifikanal',
category: 'netzwerk',
label:
(demoAktiv ? 'DEMODATEN — ' : '') +
`${survey.networks.length} WLAN-Netze` +
(eigenStk ? ` · eigenes Netz Kanal ${eigenStk.channel}, ${eigenStk.rssi} dBm` : '') +
(recommendation.length > 0 ? ` · 2,4 GHz frei: Kanal ${recommendation[0].channel}` : ''),
params: {},
result: {
// Muss im Protokoll sichtbar bleiben: eine Demomessung darf nie mit
// einer echten Messung verwechselt werden — auch nicht Wochen später
// im Kunden-PDF.
...(demoAktiv ? { demodaten: true } : {}),
anzahlNetze: survey.networks.length,
eigenesNetz: eigenStk
? {
ssid: eigenStk.ssid,
kanal: eigenStk.channel,
band: eigenStk.band,
rssi: eigenStk.rssi,
breiteMhz: eigenStk.widthMhz ?? null,
bewertung: rssiRating(eigenStk.rssi).label,
}
: null,
empfehlung24GHz: recommendation.map((r) => ({ kanal: r.channel, stoerlast: r.load })),
warnungen,
netze: survey.networks
.slice()
.sort((a, b) => b.rssi - a.rssi)
.map((n) => ({
ssid: n.ssid,
kanal: n.channel,
band: n.band,
rssi: n.rssi,
breiteMhz: n.widthMhz ?? null,
standard: n.standard ?? null,
sicherheit: n.security ?? null,
})),
},
measureStatus: status,
dateMeasure: survey.createdAt,
});
await persist();
toast.show('Momentaufnahme gespeichert und ins Protokoll übernommen', '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',
});
}
/**
* Klartext-Antwort über dem Diagramm. Vor Ort ist ein Satz schneller
* erfasst als jedes Diagramm — die Zahlen liegen ohnehin vor.
*/
const fazit = $derived.by(() => {
if (networks.length === 0) return null;
const eigenes = networks.filter((n) => n.bssid === connectedBssid);
const teile: string[] = [];
if (eigenes.length > 0) {
const stk = eigenes.sort((a, b) => b.rssi - a.rssi)[0];
const r = rssiRating(stk.rssi);
teile.push(`Dein Netz: Kanal ${stk.channel} · ${stk.rssi} dBm (${r.label})`);
}
if (recommendation.length > 0) {
const best = recommendation[0];
const eigen24 = eigenes.find((n) => n.band === '2.4 GHz');
if (eigen24 && eigen24.channel === best.channel) {
teile.push(`2,4 GHz: Kanal ${best.channel} ist bereits der störungsärmste`);
} else {
teile.push(`2,4 GHz frei­ster Kanal: ${best.channel}`);
}
}
return teile.length > 0 ? teile.join(' · ') : null;
});
</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 demoAktiv}
<p class="mt-2 rounded-lg bg-fuchsia-900/40 p-2.5 text-xs text-fuchsia-200">
<strong>Demodaten aktiv</strong> — die angezeigten Netze sind erfunden, nicht gemessen.
Eine hier gespeicherte Momentaufnahme wird als Demo gekennzeichnet. Abschalten unter
Einstellungen → WLAN-Demodaten.
</p>
{/if}
{#if fazit}
<p class="mt-2 rounded-lg bg-zinc-800 p-2.5 text-sm font-medium text-zinc-100">{fazit}</p>
{/if}
{#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 (protocol.wifiSurveys ?? []).length >= 2}
<div class="mt-3 rounded-lg bg-zinc-900 p-2.5">
<p class="mb-1 text-xs font-semibold text-zinc-300">Empfangspegel über die Momentaufnahmen</p>
<WifiTimelineChart surveys={protocol.wifiSurveys ?? []} connectedBssid={connectedBssid ?? undefined} />
</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)}
{@const farbe = netColor(n.bssid, n.bssid === connectedBssid)}
<div class="mt-1 flex items-center justify-between text-[11px] text-zinc-500">
<span class="flex min-w-0 items-center gap-1.5">
<!-- gleiche Farbe wie im Kanalgraph — macht die Zuordnung
zwischen Diagramm und Liste ohne Suchen möglich -->
<span class="h-1.5 w-1.5 shrink-0 rounded-full {farbe.dot}"></span>
<span class="truncate">
Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard
? ` · ${n.standard}`
: ''}{n.security ? ` · ${n.security}` : ''}
</span>
</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}