diff --git a/src/lib/components/WifiChannelChart.svelte b/src/lib/components/WifiChannelChart.svelte index d5a9c58..6819913 100644 --- a/src/lib/components/WifiChannelChart.svelte +++ b/src/lib/components/WifiChannelChart.svelte @@ -11,9 +11,11 @@ * ablesbar. * * Die drei tragenden Entscheidungen: - * 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1/2A, - * UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen fallen - * weg — dadurch wird ein 20-MHz-Kanal ~24 statt 8,6 Einheiten breit. + * 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1, + * UNII-2A, UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen + * fallen weg — dadurch wird ein 20-MHz-Kanal deutlich breiter als die + * 8,6 Einheiten von vorher. Die Trennung UNII-1/UNII-2A ist zusätzlich + * fachlich nötig: ab Kanal 52 gilt DFS, und das muss am Block stehen. * 2. ALLE Blöcke eines Bandes teilen sich EINEN Maßstab. Sonst wäre * derselbe 20-MHz-Kanal in UNII-3 doppelt so breit wie in UNII-2C und * man würde Balkenbreite als Kanalbreite fehllesen. @@ -60,8 +62,15 @@ highlightBssid?: string; } = $props(); - /** Höhe: Plot + zwei Zeilen Kanalbeschriftung + Blockunterschrift */ + /** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */ const H = BASELINE + 34; + /** + * Ein Block ohne Netze braucht keine volle dBm-Skala — er wird flach + * gezeichnet. Sonst kostet jedes leere Teilband so viel Platz wie ein + * belegtes, und bei vier 5-GHz-Blöcken müsste man an leeren Flächen + * vorbeiscrollen, um die belegten zu finden. + */ + const H_LEER = 52; const segmente = $derived.by(() => { const segs = BAND_SEGMENTS[band] ?? []; @@ -182,20 +191,28 @@ {#each segmente as s (s.seg.name)}
- - - - {#if s.leer} - + {#if s.leer} + + + + frei — keine Netze - {/if} + {#each s.ticks as t (t.channel)} + + {#if t.label} + {t.channel} + {/if} + {/each} + + {s.seg.name}{s.seg.dfs ? ' · DFS' : ''} + + + {:else} + + {#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)} @@ -268,5 +285,6 @@ Kanal + {/if}
{/each} diff --git a/src/lib/wifi/chart.ts b/src/lib/wifi/chart.ts index f789175..b568f17 100644 --- a/src/lib/wifi/chart.ts +++ b/src/lib/wifi/chart.ts @@ -92,8 +92,15 @@ export const BAND_SEGMENTS: Record = { channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], }, ], + // UNII-1 und UNII-2A bewusst GETRENNT, obwohl sie im Frequenzband + // aneinandergrenzen: ab Kanal 52 gilt DFS (Radar-Erkennung, die einen + // Zwangswechsel auslösen kann). Zusammengefasst trug der Block keine + // DFS-Kennzeichnung — ein im Diagramm „frei" wirkender Kanal 56 hätte den + // Kunden ungewollt auf DFS gesetzt, während die Warnung darunter das + // Gegenteil sagte (channelWarnings() zählt 52–144 als DFS). '5 GHz': [ - { name: 'UNII-1/2A', minMhz: 5150, maxMhz: 5340, channels: [36, 40, 44, 48, 52, 56, 60, 64] }, + { name: 'UNII-1', minMhz: 5150, maxMhz: 5250, channels: [36, 40, 44, 48] }, + { name: 'UNII-2A', minMhz: 5250, maxMhz: 5340, dfs: true, channels: [52, 56, 60, 64] }, { name: 'UNII-2C', minMhz: 5470, maxMhz: 5734, dfs: true, channels: [100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144] }, { name: 'UNII-3', minMhz: 5734, maxMhz: 5850, channels: [149, 153, 157, 161, 165] }, ], diff --git a/src/lib/wifi/recommend.ts b/src/lib/wifi/recommend.ts index 8d151bd..bb35cea 100644 --- a/src/lib/wifi/recommend.ts +++ b/src/lib/wifi/recommend.ts @@ -3,31 +3,46 @@ * * 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). + * durch alle sichtbaren Netze, gewichtet nach tatsächlicher spektraler + * Überlappung und Signalstärke des Störers. + * + * Wichtig — hier lag ein Fehler: die erste Fassung rechnete nur mit dem + * Abstand der Kanal*nummern* und ignorierte die Kanalbreite komplett. Ein + * 40-MHz-Netz auf Kanal 1 belegt real 2402–2442 MHz und damit auch Kanal 6 + * (2437), wurde aber wegen `|1−6| = 5` als „stört nicht" gewertet. Seit der + * Kanalgraph die Breite korrekt darstellt, widersprachen sich Bild und + * Empfehlung sichtbar: der Graph zeigte den breiten Block über Kanal 6, die + * Empfehlung nannte Kanal 1 trotzdem als den freisten. Da die Empfehlung das + * ist, was am Router eingestellt wird, führte das direkt zur falschen Wahl. + * Gerechnet wird deshalb jetzt in MHz — mit derselben `occupiedRange()`, die + * auch das Diagramm zeichnet. */ import type { WifiNetwork } from '../scanner'; +import { occupiedRange, channelToFreq } from './chart'; 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); -} +/** Ein WLAN-Kanal im 2,4-GHz-Band ist 20 MHz breit (der Kandidat, den wir belegen wollen) */ +const KANDIDAT_BREITE_MHZ = 20; /** - * 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). + * Störlast eines Kandidatenkanals durch ein einzelnes Netz. + * + * Anteil der Kandidaten-Bandbreite, den das fremde Netz spektral überdeckt + * (0…1), gewichtet mit dessen Signalstärke. Ein 40-MHz-Netz überdeckt damit + * mehr Kandidatenkanäle als ein 20-MHz-Netz — genau das, was im Kanalgraph + * auch zu sehen ist. */ function interferenceWeight(candidate: number, network: WifiNetwork): number { - const dist = channelDistance24(candidate, network.channel); - if (dist >= 5) return 0; - const overlapFactor = 1 - dist / 5; + const mitte = channelToFreq(candidate, '2.4 GHz'); + const candFrom = mitte - KANDIDAT_BREITE_MHZ / 2; + const candTo = mitte + KANDIDAT_BREITE_MHZ / 2; + + const { from, to } = occupiedRange(network); + const ueberlappungMhz = Math.min(candTo, to) - Math.max(candFrom, from); + if (ueberlappungMhz <= 0) return 0; + + const overlapFactor = Math.min(1, ueberlappungMhz / KANDIDAT_BREITE_MHZ); // 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; diff --git a/tools/render-chart.mjs b/tools/render-chart.mjs index 59348b0..d55e0c1 100644 --- a/tools/render-chart.mjs +++ b/tools/render-chart.mjs @@ -127,15 +127,21 @@ for (const s of SZENARIEN) { const { body } = render(Chart, { props: { networks: s.nets, band: s.band, highlightBssid: s.self } }); const svgs = body.match(//g) ?? []; if (svgs.length === 0) { console.log(`${s.name}: kein SVG`); continue; } - const H = Number((svgs[0].match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 190); + // Jedes Segment hat seine EIGENE Hoehe (leere Baender werden kompakt + // gezeichnet) — stapeln muss daher kumulativ erfolgen, sonst zeigt das + // Testbild kuenstliche Luecken, die es in der App nicht gibt. + const hoehen = svgs.map((sv) => Number((sv.match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 190)); + const gesamt = hoehen.reduce((a, b) => a + b, 0); + let yOff = 0; + const teile = svgs.map((sv, i) => { + const g = `` + + farbig(sv).replace(/^]*>/, '').replace(/<\/svg>$/, '') + ``; + yOff += hoehen[i]; + return g; + }); const kombiniert = - `` + - `` + - svgs.map((sv, i) => - `` + - farbig(sv).replace(/^]*>/, '').replace(/<\/svg>$/, '') + - ``).join('') + - ``; + `` + + `` + teile.join('') + ``; writeFileSync(pathResolve(OUT, `${s.name}.svg`), kombiniert); console.log(`gerendert: .render/${s.name}.svg (${s.band}, ${s.nets.length} Netze, ${svgs.length} Segment(e))`); } diff --git a/tools/test-recommend.mjs b/tools/test-recommend.mjs new file mode 100644 index 0000000..85d6b54 --- /dev/null +++ b/tools/test-recommend.mjs @@ -0,0 +1,61 @@ +import { transformSync } from 'esbuild'; +import { readFileSync, writeFileSync } from 'fs'; +import { pathToFileURL } from 'url'; + +function build(src, out) { + let js = transformSync(readFileSync(src, 'utf8'), { loader: 'ts', format: 'esm', target: 'node20' }).code; + js = js.replace(/from\s+['"]\.\/chart['"]/g, "from './chart.js'"); + writeFileSync(out, js); +} +build('src/lib/wifi/chart.ts', '/tmp/chart.js'); +build('src/lib/wifi/recommend.ts', '/tmp/recommend.js'); +const { recommend24GhzChannels } = await import(pathToFileURL('/tmp/recommend.js').href); + +function alt(nets) { + const w = (c, n) => { + const d = Math.abs(c - n.channel); + if (d >= 5) return 0; + return (1 - d / 5) * Math.min(1, Math.max(0.1, (n.rssi + 90) / 60)); + }; + return [1, 6, 11].map((channel) => ({ + channel, load: Math.round(nets.reduce((s, n) => s + w(channel, n), 0) * 100) / 100, + })).sort((a, b) => a.load - b.load); +} + +const FAELLE = [ + { + name: 'Demo-Lage (wie im Screenshot)', + nets: [ + { channel: 6, frequency: 2437, rssi: -53, band: '2.4 GHz', widthMhz: 20 }, + { channel: 11, frequency: 2462, rssi: -61, band: '2.4 GHz', widthMhz: 20 }, + { channel: 1, frequency: 2412, rssi: -75, band: '2.4 GHz', widthMhz: 40, centerFreq0: 2422 }, + { channel: 11, frequency: 2462, rssi: -77, band: '2.4 GHz', widthMhz: 20 }, + { channel: 13, frequency: 2472, rssi: -85, band: '2.4 GHz', widthMhz: 20 }, + ], + }, + { + name: 'STARKES 40-MHz-Netz auf Kanal 1, schwache Nachbarn auf 6 und 11', + nets: [ + { channel: 1, frequency: 2412, rssi: -45, band: '2.4 GHz', widthMhz: 40, centerFreq0: 2422 }, + { channel: 6, frequency: 2437, rssi: -85, band: '2.4 GHz', widthMhz: 20 }, + { channel: 11, frequency: 2462, rssi: -80, band: '2.4 GHz', widthMhz: 20 }, + ], + }, + { + name: 'Starkes 40-MHz-Netz auf Kanal 9 (überdeckt 6 UND 11)', + nets: [ + { channel: 9, frequency: 2452, rssi: -50, band: '2.4 GHz', widthMhz: 40, centerFreq0: 2442 }, + { channel: 1, frequency: 2412, rssi: -70, band: '2.4 GHz', widthMhz: 20 }, + ], + }, +]; + +for (const f of FAELLE) { + const neu = recommend24GhzChannels(f.nets); + const a = alt(f.nets); + const kippt = neu[0].channel !== a[0].channel; + console.log(`\n${f.name}`); + console.log(` ALT: ${a.map(r => `K${r.channel}=${r.load}`).join(' ')} -> empfiehlt Kanal ${a[0].channel}`); + console.log(` NEU: ${neu.map(r => `K${r.channel}=${r.load}`).join(' ')} -> empfiehlt Kanal ${neu[0].channel}`); + console.log(` ${kippt ? '>>> EMPFEHLUNG KIPPT — alte Formel hätte den falschen Kanal genannt' : ' (gleiche Empfehlung, nur andere Bewertung)'}`); +}