Compare commits
3 commits
2d2fa3836d
...
c98f2b1152
| Author | SHA1 | Date | |
|---|---|---|---|
| c98f2b1152 | |||
| 2e42140e1f | |||
| 3898f2aa27 |
6 changed files with 158 additions and 41 deletions
|
|
@ -192,6 +192,16 @@ export function syncProtocol(protocol: Protocol): Promise<SyncResult> {
|
||||||
vendor: d.vendor ?? '',
|
vendor: d.vendor ?? '',
|
||||||
deviceType: d.deviceType ?? '',
|
deviceType: d.deviceType ?? '',
|
||||||
note: d.note ?? '',
|
note: d.note ?? '',
|
||||||
|
// Diese Merkmale ermittelt der IP-Scan, sie blieben aber bisher auf
|
||||||
|
// dem Gerät: im Kundenprotokoll stand nur die IP, während der
|
||||||
|
// Techniker in der App „Drucker HP, Port 9100" sah.
|
||||||
|
netbiosName: d.netbiosName ?? '',
|
||||||
|
mdnsName: d.mdnsName ?? '',
|
||||||
|
mdnsServices: d.mdnsServices ?? [],
|
||||||
|
customName: d.customName ?? '',
|
||||||
|
openPorts: d.openPorts ?? [],
|
||||||
|
foundVia: d.foundVia ?? '',
|
||||||
|
lastSeen: d.lastSeen ?? null,
|
||||||
})),
|
})),
|
||||||
measurements: protocol.measurements.map((m) => ({
|
measurements: protocol.measurements.map((m) => ({
|
||||||
deviceClientId: m.deviceClientId ?? null,
|
deviceClientId: m.deviceClientId ?? null,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
* ablesbar.
|
* ablesbar.
|
||||||
*
|
*
|
||||||
* Die drei tragenden Entscheidungen:
|
* Die drei tragenden Entscheidungen:
|
||||||
* 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1/2A,
|
* 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1,
|
||||||
* UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen fallen
|
* UNII-2A, UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen
|
||||||
* weg — dadurch wird ein 20-MHz-Kanal ~24 statt 8,6 Einheiten breit.
|
* 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
|
* 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
|
* derselbe 20-MHz-Kanal in UNII-3 doppelt so breit wie in UNII-2C und
|
||||||
* man würde Balkenbreite als Kanalbreite fehllesen.
|
* man würde Balkenbreite als Kanalbreite fehllesen.
|
||||||
|
|
@ -60,8 +62,15 @@
|
||||||
highlightBssid?: string;
|
highlightBssid?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
/** Höhe: Plot + zwei Zeilen Kanalbeschriftung + Blockunterschrift */
|
/** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */
|
||||||
const H = BASELINE + 34;
|
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 segmente = $derived.by(() => {
|
||||||
const segs = BAND_SEGMENTS[band] ?? [];
|
const segs = BAND_SEGMENTS[band] ?? [];
|
||||||
|
|
@ -182,20 +191,28 @@
|
||||||
|
|
||||||
{#each segmente as s (s.seg.name)}
|
{#each segmente as s (s.seg.name)}
|
||||||
<div class="mt-1.5">
|
<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}
|
{#if s.leer}
|
||||||
<text
|
<!-- Kompaktzeile: kein Netz im Block, also keine dBm-Skala nötig.
|
||||||
x={PAD_L + s.plotW / 2}
|
Der Block wird trotzdem gezeigt — er ist die Antwort auf „wo ist
|
||||||
y={BASELINE - 12}
|
noch Platz". -->
|
||||||
text-anchor="middle"
|
<svg viewBox="0 0 {W} {H_LEER}" class="w-full" role="img" aria-label="{s.seg.name}: keine Netze">
|
||||||
class="fill-emerald-500/70"
|
<line x1={PAD_L} y1={26} x2={PAD_L + s.plotW} y2={26} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
|
||||||
style="font-size: {FS_LABEL}px"
|
<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
|
frei — keine Netze
|
||||||
</text>
|
</text>
|
||||||
|
{#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}
|
{/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 -->
|
<!-- 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)}
|
{#each [...s.bloecke].sort((a, b) => a.n.rssi - b.n.rssi) as b (b.n.bssid)}
|
||||||
|
|
@ -268,5 +285,6 @@
|
||||||
Kanal
|
Kanal
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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],
|
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': [
|
'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-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] },
|
{ 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
|
* 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
|
* 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,
|
* durch alle sichtbaren Netze, gewichtet nach tatsächlicher spektraler
|
||||||
* Adjacent-Channel (überlappender Bereich, z.B. Kanal 4 bei Kanal 6) zählt
|
* Überlappung und Signalstärke des Störers.
|
||||||
* abgeschwächt nach Abstand. Gewichtet zusätzlich mit der Signalstärke des
|
*
|
||||||
* störenden Netzes (ein schwaches Fremdnetz stört weniger als ein starkes).
|
* 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 type { WifiNetwork } from '../scanner';
|
||||||
|
import { occupiedRange, channelToFreq } from './chart';
|
||||||
|
|
||||||
const NONOVERLAPPING_24 = [1, 6, 11];
|
const NONOVERLAPPING_24 = [1, 6, 11];
|
||||||
|
/** Ein WLAN-Kanal im 2,4-GHz-Band ist 20 MHz breit (der Kandidat, den wir belegen wollen) */
|
||||||
/** Kanalabstand zweier 2,4-GHz-Kanäle in "Kanal-Einheiten" (5 MHz je Kanal) */
|
const KANDIDAT_BREITE_MHZ = 20;
|
||||||
function channelDistance24(a: number, b: number): number {
|
|
||||||
return Math.abs(a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Störlast eines Kandidatenkanals durch ein einzelnes Netz — 1.0 bei
|
* Störlast eines Kandidatenkanals durch ein einzelnes Netz.
|
||||||
* 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
|
* Anteil der Kandidaten-Bandbreite, den das fremde Netz spektral überdeckt
|
||||||
* Signalstärke (stärkeres Fremdnetz stört mehr).
|
* (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 {
|
function interferenceWeight(candidate: number, network: WifiNetwork): number {
|
||||||
const dist = channelDistance24(candidate, network.channel);
|
const mitte = channelToFreq(candidate, '2.4 GHz');
|
||||||
if (dist >= 5) return 0;
|
const candFrom = mitte - KANDIDAT_BREITE_MHZ / 2;
|
||||||
const overlapFactor = 1 - dist / 5;
|
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
|
// 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));
|
const strengthFactor = Math.min(1, Math.max(0.1, (network.rssi + 90) / 60));
|
||||||
return overlapFactor * strengthFactor;
|
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 { body } = render(Chart, { props: { networks: s.nets, band: s.band, highlightBssid: s.self } });
|
||||||
const svgs = body.match(/<svg[\s\S]*?<\/svg>/g) ?? [];
|
const svgs = body.match(/<svg[\s\S]*?<\/svg>/g) ?? [];
|
||||||
if (svgs.length === 0) { console.log(`${s.name}: kein SVG`); continue; }
|
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 =
|
const kombiniert =
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${H * svgs.length}" width="960" height="${H * svgs.length * 3}">` +
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 ${gesamt}" width="960" height="${gesamt * 3}">` +
|
||||||
`<rect width="320" height="${H * svgs.length}" fill="#18181b"/>` +
|
`<rect width="320" height="${gesamt}" fill="#18181b"/>` + teile.join('') + `</svg>`;
|
||||||
svgs.map((sv, i) =>
|
|
||||||
`<g transform="translate(0,${i * H})">` +
|
|
||||||
farbig(sv).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '') +
|
|
||||||
`</g>`).join('') +
|
|
||||||
`</svg>`;
|
|
||||||
writeFileSync(pathResolve(OUT, `${s.name}.svg`), kombiniert);
|
writeFileSync(pathResolve(OUT, `${s.name}.svg`), kombiniert);
|
||||||
console.log(`gerendert: .render/${s.name}.svg (${s.band}, ${s.nets.length} Netze, ${svgs.length} Segment(e))`);
|
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