Compare commits

..

No commits in common. "c98f2b11522f9fdab2cea459d25ac4107189105f" and "2d2fa3836dd3e2b65b0ab0a5acc7390b40848d89" have entirely different histories.

6 changed files with 41 additions and 158 deletions

View file

@ -192,16 +192,6 @@ 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,

View file

@ -11,11 +11,9 @@
* ablesbar. * ablesbar.
* *
* Die drei tragenden Entscheidungen: * Die drei tragenden Entscheidungen:
* 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1, * 1. Das Band wird in seine regulatorischen Blöcke geteilt (UNII-1/2A,
* UNII-2A, UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen * UNII-2C, UNII-3), die in Europa ungenutzten Lücken dazwischen fallen
* fallen weg — dadurch wird ein 20-MHz-Kanal deutlich breiter als die * weg — dadurch wird ein 20-MHz-Kanal ~24 statt 8,6 Einheiten breit.
* 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.
@ -62,15 +60,8 @@
highlightBssid?: string; highlightBssid?: string;
} = $props(); } = $props();
/** Höhe: Plot + Kanalbeschriftung + Blockunterschrift */ /** Höhe: Plot + zwei Zeilen 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] ?? [];
@ -191,29 +182,21 @@
{#each segmente as s (s.seg.name)} {#each segmente as s (s.seg.name)}
<div class="mt-1.5"> <div class="mt-1.5">
{#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>
{#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}"> <svg viewBox="0 0 {W} {H}" class="w-full" role="img" aria-label="Kanalbelegung {s.seg.name}">
<RssiAxis /> <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"
>
frei — keine Netze
</text>
{/if}
<!-- 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)}
<path d={b.pfad} class="{b.farbe.fill} {b.farbe.stroke}" stroke-width={b.isSelf ? 1.6 : 1} /> <path d={b.pfad} class="{b.farbe.fill} {b.farbe.stroke}" stroke-width={b.isSelf ? 1.6 : 1} />
@ -285,6 +268,5 @@
Kanal Kanal
</text> </text>
</svg> </svg>
{/if}
</div> </div>
{/each} {/each}

View file

@ -92,15 +92,8 @@ 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 52144 als DFS).
'5 GHz': [ '5 GHz': [
{ name: 'UNII-1', minMhz: 5150, maxMhz: 5250, channels: [36, 40, 44, 48] }, { name: 'UNII-1/2A', minMhz: 5150, maxMhz: 5340, channels: [36, 40, 44, 48, 52, 56, 60, 64] },
{ 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] },
], ],

View file

@ -3,46 +3,31 @@
* *
* 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, gewichtet nach tatsächlicher spektraler * durch alle sichtbaren Netze Co-Channel (gleicher Kanal) zählt voll,
* Überlappung und Signalstärke des Störers. * 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
* Wichtig hier lag ein Fehler: die erste Fassung rechnete nur mit dem * störenden Netzes (ein schwaches Fremdnetz stört weniger als ein starkes).
* Abstand der Kanal*nummern* und ignorierte die Kanalbreite komplett. Ein
* 40-MHz-Netz auf Kanal 1 belegt real 24022442 MHz und damit auch Kanal 6
* (2437), wurde aber wegen `|16| = 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) */
const KANDIDAT_BREITE_MHZ = 20; /** 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. * Störlast eines Kandidatenkanals durch ein einzelnes Netz 1.0 bei
* * Co-Channel, linear abnehmend bis 0 bei einem Abstand von 5 Kanälen
* Anteil der Kandidaten-Bandbreite, den das fremde Netz spektral überdeckt * (= kein spürbarer Überlapp mehr bei 20-MHz-Kanälen), gewichtet mit der
* (01), gewichtet mit dessen Signalstärke. Ein 40-MHz-Netz überdeckt damit * Signalstärke (stärkeres Fremdnetz stört mehr).
* 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 mitte = channelToFreq(candidate, '2.4 GHz'); const dist = channelDistance24(candidate, network.channel);
const candFrom = mitte - KANDIDAT_BREITE_MHZ / 2; if (dist >= 5) return 0;
const candTo = mitte + KANDIDAT_BREITE_MHZ / 2; const overlapFactor = 1 - dist / 5;
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;

View file

@ -127,21 +127,15 @@ 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; }
// Jedes Segment hat seine EIGENE Hoehe (leere Baender werden kompakt const H = Number((svgs[0].match(/viewBox="0 0 \d+ (\d+)"/) ?? [])[1] ?? 190);
// 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 ${gesamt}" width="960" height="${gesamt * 3}">` + `<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="${gesamt}" fill="#18181b"/>` + teile.join('') + `</svg>`; `<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>`;
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))`);
} }

View file

@ -1,61 +0,0 @@
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)'}`);
}