diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2fc9f39..e99466a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + + + + + {#each view.ticks as t (t.ch)} + + {t.ch} + {/each} + + + {#each [...view.bumps].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)} + + {/each} + + + {#each view.bumps as b (b.n.bssid)} + + {b.n.ssid || b.n.bssid.slice(-5)} + + {/each} + + {/if} +{/if} diff --git a/src/lib/db.ts b/src/lib/db.ts index bd65bc9..8dcd79f 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -29,6 +29,7 @@ function normalizeProtocol(p: Protocol): Protocol { p.savedScans ??= []; p.monitorSessions ??= []; p.wifiTrackSessions ??= []; + p.wifiSurveys ??= []; p.stressTestSessions ??= []; return p; } diff --git a/src/lib/protocols.ts b/src/lib/protocols.ts index e86d86e..6171e71 100644 --- a/src/lib/protocols.ts +++ b/src/lib/protocols.ts @@ -8,6 +8,7 @@ import type { Measurement, Protocol, SavedScan, + WifiSurvey, WifiTrackSession, } from './types'; @@ -121,3 +122,9 @@ export function addWifiTrackSession( (protocol.wifiTrackSessions ??= []).push(s); return s; } + +/** Kanal-Momentaufnahme zum Protokoll hinzufügen (für Vorher/Nachher-Vergleich beim Kanalwechsel) */ +export function addWifiSurvey(protocol: Protocol, s: WifiSurvey): WifiSurvey { + (protocol.wifiSurveys ??= []).push(s); + return s; +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index a7aee7f..355cb85 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -165,6 +165,17 @@ export interface WifiNetwork { channel: number; rssi: number; band: string; + frequency: number; + /** Kanalbreite in MHz (20/40/80/160/320) — fehlt vor Android 6 */ + widthMhz?: number; + centerFreq0?: number; + centerFreq1?: number; + /** z.B. "Wi-Fi 6 (802.11ax)" — nur ab Android 11 (API 30) gemeldet */ + standard?: string; + /** "offen" | "WEP" | "WPA" | "WPA2" | "WPA3" */ + security?: string; + /** wie alt das Scan-Ergebnis ist (ms) — alte Cache-Treffer sind kein Grund zur Sorge, wohl aber irreführend, wenn sie als "gerade jetzt" verstanden werden */ + ageMs?: number; } export interface DhcpLease { /** DHCP-Server, von dem das Gerät seine IP bezieht ('' wenn unbekannt) */ @@ -232,8 +243,18 @@ export interface NetDiagScannerPlugin { portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>; /** Ping-Qualität (Latenz, Jitter, Paketverlust) */ pingQuality(opts: { host: string; count: number }): Promise; - /** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */ - wifiScan(): Promise<{ networks: WifiNetwork[] }>; + /** + * WLAN-Scan: umliegende Netze, Kanäle, Signalstärke. Mit `trigger: true` + * wird vorher ein frischer Scan angestoßen und auf das tatsächliche + * Scan-Ende gewartet (statt fest 800 ms zu schlafen und zu hoffen). + */ + wifiScan(opts?: { trigger?: boolean }): Promise<{ + networks: WifiNetwork[]; + /** true = auf einen angeforderten frischen Scan wurde vergeblich gewartet (Timeout) — Ergebnis ist dann der letzte Cache-Stand */ + timedOut?: boolean; + }>; + /** Öffnet die System-Ortungseinstellungen — für den Hinweis, wenn WLAN-Scan wegen ausgeschalteter Ortung leer bleibt (vor Android 13) */ + openLocationSettings(): Promise<{ ok: boolean }>; /** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */ dhcpInfo(): Promise; /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ @@ -548,14 +569,81 @@ const mock: NetDiagScannerPlugin = { }; }, async wifiScan() { + // Bewusst mit ein paar "Fallstricken" bestückt, damit sich Kanalgraph, + // Gruppierung und Warnregeln auch im Browser-Dev ohne Gerät zeigen: + // Mesh (gleiche SSID auf zwei BSSIDs), ein 40-MHz-Netz im 2,4-GHz-Band + // (Warnregel), ein DFS/160-MHz-Netz im 5-GHz-Band (Warnregel). return { networks: [ - { ssid: 'AllesWattLaeuft', bssid: 'AA:BB:CC:11:22:33', channel: 6, rssi: -52, band: '2.4 GHz' }, - { ssid: 'Nachbar-WLAN', bssid: 'DD:EE:FF:44:55:66', channel: 11, rssi: -78, band: '2.4 GHz' }, - { ssid: 'AllesWattLaeuft-5G', bssid: 'AA:BB:CC:11:22:34', channel: 36, rssi: -58, band: '5 GHz' }, + { + ssid: 'AllesWattLaeuft', + bssid: 'AA:BB:CC:11:22:33', + channel: 6, + frequency: 2437, + rssi: -52, + band: '2.4 GHz', + widthMhz: 20, + standard: 'Wi-Fi 5 (802.11ac)', + security: 'WPA2', + ageMs: 1200, + }, + { + // Mesh-Knoten desselben Netzes, eigener Kanal + ssid: 'AllesWattLaeuft', + bssid: 'AA:BB:CC:11:22:99', + channel: 1, + frequency: 2412, + rssi: -68, + band: '2.4 GHz', + widthMhz: 20, + standard: 'Wi-Fi 5 (802.11ac)', + security: 'WPA2', + ageMs: 3400, + }, + { + ssid: 'AllesWattLaeuft-5G', + bssid: 'AA:BB:CC:11:22:34', + channel: 36, + frequency: 5180, + rssi: -58, + band: '5 GHz', + widthMhz: 160, + centerFreq0: 5250, + standard: 'Wi-Fi 6 (802.11ax)', + security: 'WPA3', + ageMs: 900, + }, + { + ssid: 'Nachbar-WLAN', + bssid: 'DD:EE:FF:44:55:66', + channel: 9, + frequency: 2452, + rssi: -78, + band: '2.4 GHz', + // 40 MHz im 2,4-GHz-Band überlappt praktisch immer mit Nachbarn — Warnregel + widthMhz: 40, + standard: 'Wi-Fi 4 (802.11n)', + security: 'WPA2', + ageMs: 5000, + }, + { + ssid: 'FritzBox-Gast', + bssid: '11:22:33:44:55:66', + channel: 11, + frequency: 2462, + rssi: -82, + band: '2.4 GHz', + widthMhz: 20, + security: 'offen', + ageMs: 6100, + }, ], + timedOut: false, }; }, + async openLocationSettings() { + return { ok: true }; + }, async dhcpInfo() { return { server: '192.168.1.1', diff --git a/src/lib/types.ts b/src/lib/types.ts index 6cbfefc..75de64f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -190,6 +190,36 @@ export interface StressBucket { lossPct: number; } +/** Ein WLAN-Netz innerhalb einer Kanal-Momentaufnahme (`WifiSurvey`) */ +export interface WifiSurveyNetwork { + ssid: string; + bssid: string; + channel: number; + frequency: number; + band: string; + rssi: number; + widthMhz?: number; + centerFreq0?: number; + centerFreq1?: number; + standard?: string; + security?: string; +} + +/** + * Momentaufnahme ALLER sichtbaren WLAN-Netze zu einem Zeitpunkt (Kanäle, + * Breite, Signalstärke) — Grundlage für den Kanalgraph, den Zeitverlauf + * mehrerer Netze (mehrere Momentaufnahmen übereinandergelegt) und den + * Vorher/Nachher-Vergleich beim Kanalwechsel. NUR LOKAL, nicht synchronisiert + * (wie WifiTrackSession) — reine Vor-Ort-Diagnosehilfe. + */ +export interface WifiSurvey { + id: string; + createdAt: number; + /** benutzervergebene Bezeichnung, z.B. "vor Kanalwechsel" */ + label?: string; + networks: WifiSurveyNetwork[]; +} + /** * Laufende oder gerade beendete Dauer-/Stresstest-Sitzung — NUR LOKAL * (nicht synchronisiert, wie DeviceMonitorSession/WifiTrackSession). Dient der @@ -267,6 +297,8 @@ export interface Protocol { monitorSessions?: DeviceMonitorSession[]; /** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */ wifiTrackSessions?: WifiTrackSession[]; + /** WLAN-Kanal-Momentaufnahmen (nur lokal, wird nicht synchronisiert) */ + wifiSurveys?: WifiSurvey[]; /** Dauer-/Stresstest-Sessions (nur lokal — das Ergebnis geht als Measurement raus) */ stressTestSessions?: StressTestSession[]; /** true solange noch nicht zum Server synchronisiert */ diff --git a/src/lib/wifi/rating.ts b/src/lib/wifi/rating.ts new file mode 100644 index 0000000..5bdeea1 --- /dev/null +++ b/src/lib/wifi/rating.ts @@ -0,0 +1,31 @@ +/** + * Gemeinsame WLAN-Empfangsbewertung (RSSI in dBm). + * + * Vorher gab es drei Kopien mit unterschiedlichen Schwellen: `iptest/+page.svelte` + * und `wifi/+page.svelte` hatten je eine eigene `rssiColor()` (beide -55/-70, + * zufällig gleich), UND `wifi/+page.svelte` hatte zusätzlich eine `rssiLabel()` + * mit eigenen, davon abweichenden Schwellen (-55/-65/-75) — derselbe Messwert + * konnte je nach Stelle unterschiedlich benannt werden. Diese Funktion ist jetzt + * die einzige Stelle, die entscheidet. + * + * Schwellen orientieren sich an gängigen WLAN-Planungswerten: ab -55 dBm + * exzellent, ab -67 dBm noch zuverlässig für Video/Voice, ab -75 dBm gerade + * noch brauchbar (Web/E-Mail), darunter unzuverlässig. + */ + +import type { MeasureStatus } from '../types'; + +export interface RssiRating { + label: string; + colorClass: string; + /** Ampel-Bewertung — 0 ok, 1 warn, 2 fail */ + status: MeasureStatus; +} + +export function rssiRating(rssi: number | undefined | null): RssiRating { + if (rssi == null) return { label: '—', colorClass: 'text-zinc-500', status: 1 }; + if (rssi >= -55) return { label: 'sehr gut', colorClass: 'text-emerald-400', status: 0 }; + if (rssi >= -67) return { label: 'gut', colorClass: 'text-emerald-400', status: 0 }; + if (rssi >= -75) return { label: 'mäßig', colorClass: 'text-amber-400', status: 1 }; + return { label: 'schwach', colorClass: 'text-red-400', status: 2 }; +} diff --git a/src/lib/wifi/recommend.ts b/src/lib/wifi/recommend.ts new file mode 100644 index 0000000..8d151bd --- /dev/null +++ b/src/lib/wifi/recommend.ts @@ -0,0 +1,89 @@ +/** + * Kanalempfehlung und Warnregeln fürs 2,4-/5-GHz-Band. + * + * Bewusst einfach gehalten (keine vollständige RF-Simulation): zählt für + * jeden der drei nicht überlappenden 2,4-GHz-Kanäle (1/6/11) die Störlast + * durch alle sichtbaren Netze — Co-Channel (gleicher Kanal) zählt voll, + * Adjacent-Channel (überlappender Bereich, z.B. Kanal 4 bei Kanal 6) zählt + * abgeschwächt nach Abstand. Gewichtet zusätzlich mit der Signalstärke des + * störenden Netzes (ein schwaches Fremdnetz stört weniger als ein starkes). + */ + +import type { WifiNetwork } from '../scanner'; + +const NONOVERLAPPING_24 = [1, 6, 11]; + +/** Kanalabstand zweier 2,4-GHz-Kanäle in "Kanal-Einheiten" (5 MHz je Kanal) */ +function channelDistance24(a: number, b: number): number { + return Math.abs(a - b); +} + +/** + * Störlast eines Kandidatenkanals durch ein einzelnes Netz — 1.0 bei + * Co-Channel, linear abnehmend bis 0 bei einem Abstand von 5 Kanälen + * (= kein spürbarer Überlapp mehr bei 20-MHz-Kanälen), gewichtet mit der + * Signalstärke (stärkeres Fremdnetz stört mehr). + */ +function interferenceWeight(candidate: number, network: WifiNetwork): number { + const dist = channelDistance24(candidate, network.channel); + if (dist >= 5) return 0; + const overlapFactor = 1 - dist / 5; + // RSSI -30 (sehr stark) -> Gewicht ~1, RSSI -90 (kaum sichtbar) -> Gewicht ~0.1 + const strengthFactor = Math.min(1, Math.max(0.1, (network.rssi + 90) / 60)); + return overlapFactor * strengthFactor; +} + +export interface ChannelScore { + channel: number; + /** niedriger = besser (weniger Störlast) */ + load: number; +} + +/** + * Beste der drei nicht überlappenden 2,4-GHz-Kanäle (1/6/11), aufsteigend + * nach Störlast sortiert. `networks` sollte NUR Fremdnetze enthalten (das + * eigene Netz selbst zählt nicht als Störer für sich selbst). + */ +export function recommend24GhzChannels(networks: WifiNetwork[]): ChannelScore[] { + const in24 = networks.filter((n) => n.band === '2.4 GHz'); + return NONOVERLAPPING_24.map((channel) => ({ + channel, + load: Math.round(in24.reduce((sum, n) => sum + interferenceWeight(channel, n), 0) * 100) / 100, + })).sort((a, b) => a.load - b.load); +} + +/** + * Zwei feste Warnregeln, die praktisch immer ein Problem anzeigen: + * 1. 40 MHz Kanalbreite im 2,4-GHz-Band — bei realistischer Nachbarschafts- + * dichte (Mehrfamilienhaus, Gewerbegebiet) fast immer eine Interferenz- + * quelle, weil es 2 der 3 nicht überlappenden Kanäle gleichzeitig belegt. + * 2. 160 MHz Kanalbreite (oder DFS-Kanäle 52-144) im 5-GHz-Band — DFS kann + * durch Radar-Erkennung jederzeit einen Kanalwechsel erzwingen (kurzer + * Aussetzer), 160 MHz ist auf vielen Consumer-Geräten instabil bzw. wird + * bei Störung automatisch auf 80 MHz zurückgestuft. + */ +export function channelWarnings(networks: WifiNetwork[]): string[] { + const warnings: string[] = []; + + const wide24 = networks.filter((n) => n.band === '2.4 GHz' && (n.widthMhz ?? 20) >= 40); + for (const n of wide24) { + warnings.push( + `„${n.ssid || n.bssid}" nutzt 40 MHz im 2,4-GHz-Band (Kanal ${n.channel}) — blockiert damit ` + + `praktisch 2 der 3 störungsfreien Kanäle. In dicht besiedelter Umgebung meist ein Fehler in ` + + `der Router-Konfiguration, nicht Absicht.`, + ); + } + + const isDfsChannel = (ch: number) => ch >= 52 && ch <= 144; + const wideOrDfs5 = networks.filter( + (n) => n.band === '5 GHz' && ((n.widthMhz ?? 20) >= 160 || isDfsChannel(n.channel)), + ); + for (const n of wideOrDfs5) { + const reasons: string[] = []; + if ((n.widthMhz ?? 20) >= 160) reasons.push('160 MHz Kanalbreite'); + if (isDfsChannel(n.channel)) reasons.push('DFS-Kanal (Radar-Erkennung kann Kanalwechsel erzwingen)'); + warnings.push(`„${n.ssid || n.bssid}" (Kanal ${n.channel}): ${reasons.join(', ')} — kann zu kurzen, scheinbar grundlosen Aussetzern führen.`); + } + + return warnings; +} diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index 2b71f98..055244e 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -360,6 +360,17 @@ Empfangsstärke beim Durchgehen aufzeichnen. + + + + WLAN-Kanäle + + Kanalbelegung, Überlappung und Kanalempfehlung — alle Netze auf einen Blick. + + diff --git a/src/routes/protokoll/[id]/iptest/+page.svelte b/src/routes/protokoll/[id]/iptest/+page.svelte index 392d454..9314e22 100644 --- a/src/routes/protokoll/[id]/iptest/+page.svelte +++ b/src/routes/protokoll/[id]/iptest/+page.svelte @@ -17,6 +17,7 @@ import { getProtocol, saveProtocol } from '$lib/db'; import { addMeasurement } from '$lib/protocols'; import { scanner } from '$lib/scanner'; + import { rssiRating } from '$lib/wifi/rating'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; import type { LinkInfo, Protocol } from '$lib/types'; @@ -85,10 +86,7 @@ } function rssiColor(rssi?: number): 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'; + return rssiRating(rssi).colorClass; } async function persist(): Promise { diff --git a/src/routes/protokoll/[id]/wifi/+page.svelte b/src/routes/protokoll/[id]/wifi/+page.svelte index 838a9d3..8cdc831 100644 --- a/src/routes/protokoll/[id]/wifi/+page.svelte +++ b/src/routes/protokoll/[id]/wifi/+page.svelte @@ -20,6 +20,7 @@ type WifiNetwork, type WifiSignalEvent, } from '$lib/scanner'; + import { rssiRating } from '$lib/wifi/rating'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types'; @@ -185,19 +186,14 @@ /* --- Anzeige-Helfer --- */ + // Dünne Wrapper um die gemeinsame rssiRating() — behalten die bisherigen + // Aufrufstellen unten unverändert (colorClass/label statt eines Objekts). 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'; + return rssiRating(rssi).colorClass; } 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'; + return rssiRating(rssi).label; } /** Aktuelles RSSI: letztes Sample der laufenden Session */ diff --git a/src/routes/protokoll/[id]/wifikanal/+page.svelte b/src/routes/protokoll/[id]/wifikanal/+page.svelte new file mode 100644 index 0000000..f4108d8 --- /dev/null +++ b/src/routes/protokoll/[id]/wifikanal/+page.svelte @@ -0,0 +1,380 @@ + + +{#if protocol} + + +
+ {#if locationError} +
+

{errorMsg}

+ +
+ {:else if errorMsg} +

{errorMsg}

+ {/if} + +
+ + +
+ + {#if changesSinceLastSurvey.length > 0} +
+

Seit der letzten Momentaufnahme ({fmtDateTime(lastSurvey.createdAt)}):

+ {#each changesSinceLastSurvey as c (c)} +

{c}

+ {/each} +
+ {/if} + + {#if warnings.length > 0} +
+ {#each warnings as w (w)} +

{w}

+ {/each} +
+ {/if} + + {#if recommendation.length > 0} +
+

Empfehlung 2,4-GHz-Kanal (weniger Störlast zuerst)

+
+ {#each recommendation as r, i (r.channel)} + + Kanal {r.channel} ({r.load}) + + {/each} +
+
+ {/if} + + {#if bandsPresent.length > 0} + {#each bandsPresent as b (b)} +
+

{b}

+ n.band === b)} band={b} highlightBssid={connectedBssid ?? undefined} /> +
+ {/each} + {:else if !scanning && !errorMsg} +

Keine Netze gefunden.

+ {/if} + + {#if timeline} +
+

+ Zeitverlauf ({fmtDateTime(timeline.tFirst)} – {fmtDateTime(timeline.tLast)}) +

+ + + {#each timeline.series as s (s.ssid)} + {#each s.segments as seg, i (i)} + + {/each} + {/each} + +
+ {#each timeline.series as s (s.ssid)} + + + {s.ssid} + + {/each} +
+
+ {/if} + +

Netze ({groups.length})

+
+ {#each groups as g (g.ssid)} +
+
+ {g.ssid} + {#if g.isConnected} + verbunden + {/if} +
+ {#each g.nets as n (n.bssid)} + {@const rating = rssiRating(n.rssi)} +
+ + Kanal {n.channel} · {n.band}{n.widthMhz ? ` · ${n.widthMhz} MHz` : ''}{n.standard + ? ` · ${n.standard}` + : ''}{n.security ? ` · ${n.security}` : ''} + + {n.rssi} dBm +
+ {/each} +
+ {/each} +
+ + {#if pastSurveys.length > 0} +

Frühere Momentaufnahmen

+ {#each pastSurveys as s (s.id)} +
+ + {#if expandedSurvey === s.id} +
+ {#each s.networks.sort((a, b) => b.rssi - a.rssi) as n (n.bssid)} +
+ {n.ssid || n.bssid} · Kanal {n.channel} + {n.rssi} dBm +
+ {/each} +
+ {/if} +
+ {/each} + {/if} +
+{:else} +
Lädt …
+{/if}