Compare commits

..

3 commits

Author SHA1 Message Date
c98f2b1152 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 2m13s
2026-08-15 18:54:13 +02:00
2e42140e1f Sync: Gerätemerkmale mitsenden (NetBIOS, mDNS, Ports, Fundweg)
Die App ermittelt seit dem IP-Scanner-Umbau NetBIOS-/mDNS-Namen, offene Ports
und den Fundweg je Gerät, hat sie aber nie übertragen — im Kundenprotokoll
stand nur die IP, während der Techniker in der App "Drucker HP, Port 9100" sah.

Der Payload enthält jetzt netbiosName, mdnsName, mdnsServices, customName,
openPorts, foundVia und lastSeen. Die Gegenstelle (Dolibarr-Modul netdiag)
nimmt sie ab derselben Version entgegen; ältere Modulstände ignorieren die
zusätzlichen Schlüssel einfach.
2026-08-15 18:54:02 +02:00
3898f2aa27 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.
2026-08-15 17:59:41 +02:00
6 changed files with 158 additions and 41 deletions

View file

@ -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,

View file

@ -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}"> {#if s.leer}
<RssiAxis /> <!-- Kompaktzeile: kein Netz im Block, also keine dBm-Skala nötig.
Der Block wird trotzdem gezeigt — er ist die Antwort auf „wo ist
{#if s.leer} noch Platz". -->
<text <svg viewBox="0 0 {W} {H_LEER}" class="w-full" role="img" aria-label="{s.seg.name}: keine Netze">
x={PAD_L + s.plotW / 2} <line x1={PAD_L} y1={26} x2={PAD_L + s.plotW} y2={26} stroke="currentColor" class="text-zinc-700" stroke-width="1" />
y={BASELINE - 12} <text x={PAD_L + s.plotW / 2} y={18} text-anchor="middle" class="fill-emerald-400/80" style="font-size: {FS_LABEL}px">
text-anchor="middle"
class="fill-emerald-500/70"
style="font-size: {FS_LABEL}px"
>
frei — keine Netze frei — keine Netze
</text> </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 --> <!-- 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}

View file

@ -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 52144 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] },
], ],

View file

@ -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 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) */
/** 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). * (01), 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;

View file

@ -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
View 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)'}`);
}