Kanalempfehlung und DFS-Anzeige mit dem Diagramm in Einklang gebracht
Zwei Widersprüche, die beim Durchsehen der offenen Punkte auffielen. Beide entstanden erst dadurch, dass der Kanalgraph seit dem letzten Commit die Wahrheit zeigt — Empfehlung und Kennzeichnung waren noch auf dem alten Stand. 1. Kanalempfehlung ignorierte die Kanalbreite. interferenceWeight() rechnete nur mit dem Abstand der Kanalnummern. Ein 40-MHz-Netz auf Kanal 1 belegt real 2402-2442 MHz und damit auch Kanal 6 (2437), wurde wegen |1-6| = 5 aber als "stört nicht" gewertet. Gerechnet wird jetzt in MHz, mit derselben occupiedRange(), die auch das Diagramm zeichnet. Belegt mit tools/test-recommend.mjs: in zwei von drei realistischen Lagen nennt die alte Formel einen ANDEREN Kanal als den tatsächlich besten — z.B. bei einem starken 40-MHz-Netz auf Kanal 1 empfahl sie Kanal 6, obwohl dieses Netz genau dorthin reicht (richtig ist Kanal 11). In der Demo-Lage ändert sich die Spitzenempfehlung nicht, nur die Bewertung der übrigen Kanäle (K6 von 0,62 auf 0,80). 2. DFS-Kanäle 52-64 waren im Diagramm nicht als solche erkennbar. Der Block hieß "UNII-1/2A" und trug keine DFS-Kennzeichnung, während channelWarnings() Kanal 52-144 als DFS behandelt. Ein im Diagramm "frei" wirkender Kanal 56 hätte den Kunden ungewollt auf DFS gesetzt (möglicher Radar-Zwangswechsel), obwohl die Warnung darunter das Gegenteil sagte. UNII-1 (36-48) und UNII-2A (52-64) sind jetzt getrennte Blöcke mit korrekter Kennzeichnung. Damit die zusätzliche Aufteilung nicht zu mehr Scrollen führt: ein Block ohne Netze wird kompakt gezeichnet (ohne dBm-Skala, ~1/4 der Höhe) statt in voller Größe. Er bleibt sichtbar — er ist die Antwort auf "wo ist noch Platz". Nebenbei im Render-Werkzeug behoben: es stapelte alle Segmente mit gleicher Höhe und erzeugte dadurch künstliche Lücken im Testbild, die es in der App nicht gibt.
This commit is contained in:
parent
2d2fa3836d
commit
3898f2aa27
5 changed files with 148 additions and 41 deletions
|
|
@ -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)}
|
||||
<div class="mt-1.5">
|
||||
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
|
||||
<RssiAxis />
|
||||
|
||||
{#if s.leer}
|
||||
<text
|
||||
x={PAD_L + s.plotW / 2}
|
||||
y={BASELINE - 12}
|
||||
text-anchor="middle"
|
||||
class="fill-emerald-500/70"
|
||||
style="font-size: {FS_LABEL}px"
|
||||
>
|
||||
{#if s.leer}
|
||||
<!-- Kompaktzeile: kein Netz im Block, also keine dBm-Skala nötig.
|
||||
Der Block wird trotzdem gezeigt — er ist die Antwort auf „wo ist
|
||||
noch Platz". -->
|
||||
<svg viewBox="0 0 {W} {H_LEER}" class="w-full" role="img" aria-label="{s.seg.name}: keine Netze">
|
||||
<line x1={PAD_L} y1={26} x2={PAD_L + s.plotW} y2={26} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||||
<text x={PAD_L + s.plotW / 2} y={18} text-anchor="middle" class="fill-emerald-400/80" style="font-size: {FS_LABEL}px">
|
||||
frei — keine Netze
|
||||
</text>
|
||||
{/if}
|
||||
{#each s.ticks as t (t.channel)}
|
||||
<line x1={t.x} y1={26} x2={t.x} y2={26 + (t.label ? 3 : 2)} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||||
{#if t.label}
|
||||
<text x={t.x} y={38} text-anchor="middle" class="fill-zinc-500" style="font-size: {FS_AXIS - 1}px">{t.channel}</text>
|
||||
{/if}
|
||||
{/each}
|
||||
<text x={PAD_L} y={H_LEER - 2} class="fill-zinc-500" style="font-size: {FS_LABEL}px">
|
||||
{s.seg.name}{s.seg.dfs ? ' · DFS' : ''}
|
||||
</text>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
|
||||
<RssiAxis />
|
||||
|
||||
<!-- Blöcke: schwächste zuerst, damit starke oben liegen -->
|
||||
{#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
|
||||
|
|
@ -268,5 +285,6 @@
|
|||
Kanal
|
||||
</text>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -92,8 +92,15 @@ export const BAND_SEGMENTS: Record<string, BandSegment[]> = {
|
|||
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] },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(/<svg[\s\S]*?<\/svg>/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 = `<g transform="translate(0,${yOff})">` +
|
||||
farbig(sv).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') + `</g>`;
|
||||
yOff += hoehen[i];
|
||||
return g;
|
||||
});
|
||||
const kombiniert =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${H * svgs.length}" width="960" height="${H * svgs.length * 3}">` +
|
||||
`<rect width="320" height="${H * svgs.length}" fill="#18181b"/>` +
|
||||
svgs.map((sv, i) =>
|
||||
`<g transform="translate(0,${i * H})">` +
|
||||
farbig(sv).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') +
|
||||
`</g>`).join('') +
|
||||
`</svg>`;
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${gesamt}" width="960" height="${gesamt * 3}">` +
|
||||
`<rect width="320" height="${gesamt}" fill="#18181b"/>` + teile.join('') + `</svg>`;
|
||||
writeFileSync(pathResolve(OUT, `${s.name}.svg`), kombiniert);
|
||||
console.log(`gerendert: .render/${s.name}.svg (${s.band}, ${s.nets.length} Netze, ${svgs.length} Segment(e))`);
|
||||
}
|
||||
|
|
|
|||
61
tools/test-recommend.mjs
Normal file
61
tools/test-recommend.mjs
Normal file
|
|
@ -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)'}`);
|
||||
}
|
||||
Loading…
Reference in a new issue