Phase 1: Messverfahren liefern keine falschen Aussagen mehr

Vier Werkzeuge haben nachweislich Unsinn ins Kundenprotokoll geschrieben. Belege
sind echte Prod-Messungen; jeder Fix wurde im Emulator gegen den passenden
Fehlerfall nachgestellt.

SNMP (Snmp.kt, snmp.ts)
  Snmp.get lieferte bei JEDEM Problem null -> "-" -> parseInt("-")||0 -> 0.
  Ein Switch, der gar nicht antwortet, stand damit als "0 Fehler" in Gruen im
  Kunden-PDF (Prod #95/#96). Neu: Ergebnistyp Value/Timeout/Failure statt
  String?, Request-ID- und error-status-Pruefung, v2c-Ausnahmen (0x80/0x81/0x82)
  erkannt, ein Wiederholversuch bei Timeout. Das Werkzeug meldet jetzt
  "nicht messbar" statt Zahlen zu erfinden.
  Test: SNMP gegen einen Host ohne Agent -> status 3 "keine SNMP-Antwort".

Traceroute (pingWithTtl, traceroute.ts)
  Der Fallback-Regex \((\d+\.\d+\.\d+\.\d+)\) traf IMMER zuerst die ping-Kopfzeile
  "PING 8.8.8.8 (8.8.8.8) ...". Ein stummer Hop wurde dadurch als Ziel ausgegeben
  und der Trace beendet — daher "2 Hops bis 8.8.8.8, beide 0 ms" (Prod #13).
  Zwischen-Hops liefern zudem nie ein time=, die Zeit war strukturell 0.
  Neu: Kopfzeile verworfen, zeilenweise geparst, Reverse-DNS-Antworten erkannt,
  RTT selbst per nanoTime gestoppt, 3 Proben je TTL, waitFor(3s)+destroyForcibly,
  "keine Antwort" ist null und nicht 0, Bewertung haengt an "Ziel erreicht".
  Test: 8.8.8.8 -> 1 Hop mit echten 13,3 ms (statt 0 ms);
        192.0.2.1 -> status 3, alle Hops "* (keine Antwort)" statt gruen.

Ping (measurePing, ping.ts)
  Kein WifiLock, keine Aufwaermproben: zwischen den Proben schlief das Funkmodul
  ein, die Aufwachzeit landete in der Messung — 296 ms zum LAN-Gateway bei 0 %
  Verlust und rotem Status (Prod #4). WAKE_LOCK fehlte im Manifest, weshalb
  WifiLock.acquire() eine SecurityException warf, die ein leerer catch schluckte.
  Neu: WAKE_LOCK im Manifest, WifiLock (LOW_LATENCY) + PARTIAL_WAKE_LOCK um jede
  Messung, 2 verworfene Aufwaermproben, Median und Messaufschlag (avg-min)
  zusaetzlich ausgewiesen, Verlust als Kommazahl statt Ganzzahl-Division,
  Bewertung auf den Median statt den Mittelwert, "keine Antwort" != "0 ms".
  Test: 20 Proben -> Median 0,3 ms, Messaufschlag 0,1 ms, Verfahren dokumentiert.

Durchsatz (measureThroughput, iperf.ts)
  Kein soTimeout: nahm die Gegenstelle an, ohne zu senden, blockierte read()
  unbegrenzt und das Werkzeug-Fenster hing dauerhaft auf "Messung laeuft ...".
  Neu: soTimeout 4 s je Phase, harter withTimeout-Guard, Ist-Zeit statt
  angenommener halber Dauer, measured-Flag — ohne uebertragene Bytes gibt es
  kein rotes "0 Mbit/s" mehr, sondern "nicht messbar".
  Test: Gegenstelle nimmt an und schweigt -> Ergebnis nach 30 s statt Dauerhaenger.

Netzerkennung (firstLocalIpv4, getLocalSubnet, hostsInSubnet)
  firstLocalIpv4() gab bei fehlendem Netz hart "192.168.1.1" zurueck; zusammen mit
  dem Praefix-Fallback /24 entstand das Phantom-Netz 192.168.1.0/24, das auch noch
  ins Protokoll geschrieben wurde. Der Fehlerzweig in ipscan.ts konnte deshalb nie
  greifen. Neu: "" statt Phantom-IP, Link-Local uebersprungen, getLocalSubnet
  meldet einen Fehler, DHCP-Fallback nur bei WLAN-Transport (sonst wurde bei
  gestecktem USB-RJ45 die Maske aus dem WLAN genommen), unlesbares CIDR-Praefix
  wird abgelehnt statt still durch /24 ersetzt.
  Test: Flugmodus -> "Kein Netzbereich — WLAN/LAN nicht aktiv?" statt Phantom-Netz.

Stresstest-Ziel (stresstest.ts)
  Vorgabe 192.168.1.1 im Kundennetz 192.168.178.0/24 -> 5 Minuten ins Leere
  gemessen, Ergebnis "100 % Verlust" rot im Protokoll (Prod #126). Neu: kein
  fester Vorgabewert, leer = Gateway des aktiven Adapters, und eine Vorlaufprobe
  bricht ab, bevor minutenlang gegen ein totes Ziel gemessen wird.
  Test: Ziel 192.168.1.1 -> nach 7 s "antwortet nicht — Test nicht gestartet"
        (vorher: 60 s Messung mit rotem 100-%-Ergebnis).

Neue Bewertung "nicht messbar" (MeasureStatus 3)
  Bisher gab es nur ok/warn/fail. Ein Test, der gar nicht durchgefuehrt werden
  konnte, musste sich als eines davon ausgeben — meist als Gruen. Status 3 wird
  neutral grau dargestellt (Messkarte und Geraetekarte) und ist keine Aussage
  ueber das Kundennetz.

Geprueft: gradle compileDebugKotlin ok, svelte-check 0 Fehler (2 vorbestehende
Warnungen), alle sieben Faelle im Emulator (Pixel 6 / Android 14) durchgespielt
und die Ergebnisse in der Datenbank gegengelesen statt am Bildschirm geraten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-08-14 19:16:17 +02:00
parent 881ca7956f
commit 240d65d534
13 changed files with 906 additions and 162 deletions

View file

@ -51,6 +51,14 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den
PARTIAL_WAKE_LOCK waehrend einer Messung. Ohne dieses Recht wirft
acquire() eine SecurityException — die Sperren waren damit wirkungslos,
das WLAN ging zwischen den Proben schlafen und die gemessenen Latenzen
enthielten die Aufwachzeit statt der Netzlaufzeit.
-->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -30,8 +30,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
@ -72,6 +74,86 @@ class NetDiagScannerPlugin : Plugin() {
private val io = CoroutineScope(Dispatchers.IO)
private val stressRuns = ConcurrentHashMap<String, StressRun>()
/* --------------------------------------------------------------------- */
/* Funk/CPU wachhalten waehrend einer Messung */
/* --------------------------------------------------------------------- */
/**
* Haelt WLAN-Funkmodul und CPU waehrend einer Messung wach.
*
* Warum das eine Messgroesse ist und keine Nebensache: Zwischen zwei Proben
* liegen Pausen von 200 ms und mehr. Ohne Lock schaltet das WLAN in den
* Energiesparmodus und muss zum naechsten Beacon aufwachen die gemessene
* Zeit enthaelt dann die Aufwachzeit statt der Netzlaufzeit. Genau so kommt
* Prod-Messung #4 zustande: 296 ms Mittel zum LAN-Gateway bei 0 % Verlust,
* und selbst der Minimalwert lag bei 207 ms.
*
* Stolperfalle: `WifiLock.acquire()` braucht `android.permission.WAKE_LOCK`.
* Fehlt das Recht, fliegt eine SecurityException die frueher ein leerer
* catch-Block verschluckt hat, der Lock war also wirkungslos ohne jeden
* Hinweis. Deshalb wird hier protokolliert.
*/
private class RadioLocks(
val wifi: WifiManager.WifiLock?,
val wake: android.os.PowerManager.WakeLock?,
)
private fun acquireRadioLocks(tag: String, timeoutMs: Long): RadioLocks {
var wifi: WifiManager.WifiLock? = null
var wake: android.os.PowerManager.WakeLock? = null
try {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION") WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wifi = wm.createWifiLock(mode, "netdiag-$tag")
wifi.acquire()
} catch (e: Exception) {
android.util.Log.w(TAG, "WifiLock ($tag) nicht moeglich: ${e.message}")
wifi = null
}
try {
val pm = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager
wake = pm.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "netdiag:$tag")
// Immer mit Zeitgrenze anfordern — ein vergessener WakeLock wuerde
// sonst den Akku leersaugen.
wake.acquire(timeoutMs)
} catch (e: Exception) {
android.util.Log.w(TAG, "WakeLock ($tag) nicht moeglich: ${e.message}")
wake = null
}
return RadioLocks(wifi, wake)
}
/**
* Laeuft das aktive Netz ueber WLAN? Entscheidet, ob die WLAN-DHCP-Info als
* Fallback fuer Maske/Gateway ueberhaupt zum aktiven Interface passt.
*/
private fun isActiveTransportWifi(): Boolean {
return try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
android.util.Log.w(TAG, "isActiveTransportWifi: ${e.message}")
false
}
}
private fun releaseRadioLocks(locks: RadioLocks) {
try { if (locks.wifi?.isHeld == true) locks.wifi.release() } catch (e: Exception) {
android.util.Log.w(TAG, "WifiLock-Freigabe: ${e.message}")
}
try { if (locks.wake?.isHeld == true) locks.wake.release() } catch (e: Exception) {
android.util.Log.w(TAG, "WakeLock-Freigabe: ${e.message}")
}
}
/* --------------------------------------------------------------------- */
/* Subnetz / lokale Netzwerkinfo */
/* --------------------------------------------------------------------- */
@ -110,7 +192,12 @@ class NetDiagScannerPlugin : Plugin() {
}
// 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer)
if (ip.isEmpty() || prefix == 0) {
// NUR wenn das aktive Netz auch wirklich WLAN ist. Sonst wuerde
// bei gestecktem USB-RJ45-Adapter (dessen LinkProperties oft
// prefixLength 0 melden) die Maske und das Gateway aus dem
// parallel verbundenen WLAN uebernommen — die App scannt dann
// ein ganz anderes Netz als das Kabel, in dem sie steckt.
if ((ip.isEmpty() || prefix == 0) && isActiveTransportWifi()) {
val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
@ -121,18 +208,31 @@ class NetDiagScannerPlugin : Plugin() {
}
}
// 3. Letzter Fallback
// 3. Letzter Fallback: irgendein aktives Interface
var prefixQuelle = if (prefix in 1..32) "adapter" else ""
if (ip.isEmpty()) ip = firstLocalIpv4()
if (prefix !in 1..32) prefix = 24
// Netzadresse aus IP + Präfix berechnen
val ipInt = ipv4ToInt(ip) ?: 0
// Kein Netz = Fehler. Frueher wurde hier "192.168.1.1" erfunden und
// mit /24 zu einem Netz zusammengebaut, das es nirgends gab.
val ipInt = ipv4ToInt(ip)
if (ip.isEmpty() || ipInt == null) {
call.reject("Kein aktives IPv4-Netz — WLAN/LAN verbunden?")
return@launch
}
if (prefix !in 1..32) {
// Nur noch als benannte Annahme, nicht mehr stillschweigend
prefix = 24
prefixQuelle = "angenommen"
}
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = intToIpv4(ipInt and mask)
resolve(call, JSObject()
.put("subnet", "$network/$prefix")
.put("ip", ip)
.put("prefix", prefix)
.put("prefixQuelle", prefixQuelle)
.put("gateway", gateway))
} catch (e: Exception) {
call.reject("getLocalSubnet: ${e.message}")
@ -152,6 +252,9 @@ class NetDiagScannerPlugin : Plugin() {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
// Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die
// Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht.
val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L))
try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
@ -196,9 +299,17 @@ class NetDiagScannerPlugin : Plugin() {
if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev)
}
resolve(call, JSObject().put("devices", devices))
resolve(call, JSObject()
.put("devices", devices)
// Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein
// gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden.
.put("probed", hosts.size)
.put("answered", alive.size)
.put("arpAvailable", arp.isNotEmpty()))
} catch (e: Exception) {
call.reject("ipScan: ${e.message}")
} finally {
releaseRadioLocks(locks)
}
}
}
@ -418,10 +529,25 @@ class NetDiagScannerPlugin : Plugin() {
* Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind
* ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16.
*/
/**
* Alle Host-Adressen eines CIDR-Bereichs.
*
* Zum Praefix: Frueher wurde ein fehlendes ODER unlesbares Praefix
* kommentarlos durch 24 ersetzt "192.168.144.0/zwanzig" ergab dann 254
* statt 4094 Adressen, und die Eingabe "192.168.153.0" ohne Praefix (siehe
* Prod-Messung #2) scannte still nur ein Viertelprozent des gemeinten Netzes.
* Ein fehlendes Praefix ist jetzt "nimm /24" (dokumentierte Annahme, der
* Aufrufer weist darauf hin), ein KAPUTTES Praefix dagegen ein Fehler.
*/
private fun hostsInSubnet(cidr: String): List<Int> {
val parts = cidr.trim().split('/')
val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList()
val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24
val prefix = if (parts.size > 1) {
// Nicht interpretierbar -> ablehnen statt raten
parts[1].trim().toIntOrNull() ?: return emptyList()
} else {
24
}
if (prefix < 0 || prefix > 32) return emptyList()
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = ipInt and mask
@ -575,29 +701,74 @@ class NetDiagScannerPlugin : Plugin() {
}
}
/**
* Ping-Messung.
*
* Bekannte Grenze des Verfahrens: `InetAddress.isReachable()` schickt auf
* Android in aller Regel KEIN ICMP (dafuer fehlen die Rechte), sondern baut
* eine TCP-Verbindung auf. Die gemessene Zeit enthaelt daher immer etwas
* Verbindungsaufbau. Was sich sauber beheben laesst, ist hier gemacht:
* - Funk/CPU bleiben waehrend der Messung wach (sonst dominiert die
* WLAN-Aufwachzeit die Werte, siehe acquireRadioLocks)
* - die ersten Proben werden verworfen (Verbindungsaufbau, ARP, DNS-Cache)
* - zusaetzlich zu avg werden min und Median ausgewiesen; der Median ist
* gegen einzelne Ausreisser robust, min zeigt was das Netz wirklich kann
* - `messaufschlagMs` (avg - min) macht sichtbar, wie viel Messverfahren
* im Wert steckt
*/
private fun measurePing(host: String, count: Int): JSObject {
val times = ArrayList<Double>()
val addr = InetAddress.getByName(host)
repeat(count) {
val t0 = System.nanoTime()
if (addr.isReachable(1000)) {
times.add((System.nanoTime() - t0) / 1_000_000.0)
val locks = acquireRadioLocks("ping", (count * 1500L) + 10_000L)
try {
val times = ArrayList<Double>()
val addr = InetAddress.getByName(host)
// Aufwaermproben: Ergebnis wird verworfen, sie wecken nur das Funkmodul
repeat(PING_WARMUP_PROBES) {
try { addr.isReachable(1000) } catch (_: Exception) { }
Thread.sleep(100)
}
Thread.sleep(200)
repeat(count) {
val t0 = System.nanoTime()
val ok = try { addr.isReachable(1000) } catch (_: Exception) { false }
if (ok) times.add((System.nanoTime() - t0) / 1_000_000.0)
Thread.sleep(200)
}
val received = times.size
// Kommazahl statt Ganzzahl-Division: 1 von 20 verlorenen Paketen sind
// 5,0 % — vorher wurde alles unter 1 % auf 0 abgeschnitten.
val loss = if (count > 0) (count - received) * 100.0 / count else 0.0
val sorted = times.sorted()
val min = sorted.firstOrNull() ?: 0.0
val max = sorted.lastOrNull() ?: 0.0
val avg = if (times.isNotEmpty()) times.average() else 0.0
val median = when {
sorted.isEmpty() -> 0.0
sorted.size % 2 == 1 -> sorted[sorted.size / 2]
else -> (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0
}
// Jitter: mittlere absolute Abweichung aufeinanderfolgender EMPFANGENER
// Proben. Verlorene Proben landen gar nicht erst in der Liste — das ist
// kein RFC-3550-Jitter, deshalb wird das Verfahren mit ausgegeben.
var jitter = 0.0
for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1])
if (times.size > 1) jitter /= (times.size - 1)
return JSObject()
.put("sent", count)
.put("received", received)
.put("lossPct", round1(loss))
.put("minMs", round1(min))
.put("avgMs", round1(avg))
.put("medianMs", round1(median))
.put("maxMs", round1(max))
.put("jitterMs", round1(jitter))
.put("messaufschlagMs", round1(if (times.isNotEmpty()) avg - min else 0.0))
.put("verfahren", "TCP-Erreichbarkeit (isReachable), kein ICMP")
} finally {
releaseRadioLocks(locks)
}
val received = times.size
val loss = ((count - received) * 100) / count
val min = times.minOrNull() ?: 0.0
val max = times.maxOrNull() ?: 0.0
val avg = if (times.isNotEmpty()) times.average() else 0.0
// Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte
var jitter = 0.0
for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1])
if (times.size > 1) jitter /= (times.size - 1)
return JSObject()
.put("sent", count).put("received", received).put("lossPct", loss)
.put("minMs", round1(min)).put("avgMs", round1(avg))
.put("maxMs", round1(max)).put("jitterMs", round1(jitter))
}
/* --------------------------------------------------------------------- */
@ -697,10 +868,36 @@ class NetDiagScannerPlugin : Plugin() {
io.launch {
try {
val values = JSObject()
for (oid in oids) {
values.put(oid, Snmp.get(host, community, oid) ?: "-")
var answered = false
var firstProblem = ""
for ((idx, oid) in oids.withIndex()) {
when (val res = Snmp.get(host, community, oid)) {
is Snmp.Result.Value -> {
answered = true
values.put(oid, res.text)
}
is Snmp.Result.Failure -> {
// Der Agent hat geantwortet, nur diese OID gibt es nicht.
// Das Geraet ist also erreichbar.
answered = true
if (firstProblem.isEmpty()) firstProblem = res.reason
}
Snmp.Result.Timeout -> {
if (firstProblem.isEmpty()) {
firstProblem = "Keine Antwort auf SNMP (Port 161)"
}
// Antwortet schon die erste OID nicht, antwortet auch der
// Rest nicht — dann nicht noch 3x ins Timeout laufen.
if (idx == 0) break
}
}
}
resolve(call, JSObject().put("values", values))
resolve(call, JSObject()
.put("values", values)
.put("answered", answered)
.put("error", firstProblem))
} catch (e: Exception) {
call.reject("snmpGet: ${e.message}")
}
@ -718,18 +915,41 @@ class NetDiagScannerPlugin : Plugin() {
try {
val hops = JSArray()
var deadStreak = 0
var reachedTarget = false
for (ttl in 1..30) {
val hop = pingWithTtl(host, ttl)
hops.put(JSObject()
.put("ttl", ttl)
.put("ip", hop.ip)
.put("ms", hop.ms))
if (hop.ip == host || hop.reachedTarget) break
// Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30
deadStreak = if (hop.ip == "*") deadStreak + 1 else 0
// Drei Proben je Hop wie bei traceroute/mtr: eine einzelne Probe
// ist bei ratenbegrenzten Routern reine Glückssache.
var ip = "*"
val times = ArrayList<Double>()
var reached = false
repeat(3) {
val h = pingWithTtl(host, ttl)
if (h.ip != "*" && ip == "*") ip = h.ip
if (h.ms != null) times.add(h.ms)
if (h.reachedTarget) reached = true
}
val hop = JSObject().put("ttl", ttl).put("ip", ip)
if (times.isNotEmpty()) {
hop.put("ms", round1(times.average()))
hop.put("minMs", round1(times.minOrNull() ?: 0.0))
} else {
// Kein Wert ist etwas anderes als 0 ms — sonst steht im
// Protokoll "0 ms" für einen Hop, der nie geantwortet hat.
hop.put("ms", null as Any?)
}
hop.put("antworten", times.size)
hops.put(hop)
if (reached) { reachedTarget = true; break }
deadStreak = if (ip == "*") deadStreak + 1 else 0
if (deadStreak >= 5) break
}
resolve(call, JSObject().put("hops", hops))
resolve(call, JSObject()
.put("hops", hops)
.put("reachedTarget", reachedTarget))
} catch (e: Exception) {
call.reject("traceroute: ${e.message}")
}
@ -747,42 +967,87 @@ class NetDiagScannerPlugin : Plugin() {
val durationSec = call.getInt("durationSec") ?: 10
io.launch {
try {
// Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle:
// Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde.
val res = measureThroughput(host, port, durationSec)
// Harte Obergrenze: selbst wenn unten etwas klemmt, loest der Call auf.
// Vorher konnte read() unbegrenzt blockieren und das Werkzeug-Fenster
// blieb dauerhaft auf "Messung laeuft ...".
val res = withTimeout((durationSec * 1000L) + 15_000L) {
measureThroughput(host, port, durationSec)
}
resolve(call, res)
} catch (e: TimeoutCancellationException) {
call.reject("Durchsatztest abgebrochen — Gegenstelle antwortet nicht")
} catch (e: Exception) {
call.reject("throughput: ${e.message}")
}
}
}
/**
* Roher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle.
*
* ACHTUNG: Das ist KEIN iperf3. iperf3 erwartet zuerst ein 37-Byte-Cookie und
* einen laengenpraefigierten JSON-Parameterblock; hier werden schlicht Bytes
* geschrieben bzw. gelesen. Gegen einen echten iperf3-Server bricht die
* Verbindung deshalb ab. Umbau steht in ROADMAP_UMSETZUNG.md Phase 1.
*
* Jede Phase bekommt einen soTimeout ohne den blockiert read()/write()
* unbegrenzt, wenn die Gegenstelle zwar annimmt, aber nichts tut.
*/
private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject {
val buf = ByteArray(64 * 1024)
var downBytes = 0L
var upBytes = 0L
var upSecs = 0.0
var downSecs = 0.0
val phaseMs = durationSec * 500L // je Haelfte der Gesamtdauer
// Upload-Phase
Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L
val t0 = System.nanoTime()
val end = System.currentTimeMillis() + phaseMs
val out = s.getOutputStream()
while (System.currentTimeMillis() < end) {
out.write(buf); upBytes += buf.size
try {
while (System.currentTimeMillis() < end) {
out.write(buf); upBytes += buf.size
}
} catch (_: Exception) {
// Gegenstelle bricht ab: gemessen wird, was bis dahin durchging
}
// Ist-Zeit statt angenommener Haelfte — ein vorzeitiger Abbruch wuerde
// sonst als niedriger Durchsatz erscheinen statt als kurze Messung.
upSecs = (System.nanoTime() - t0) / 1_000_000_000.0
}
// Download-Phase
Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000)
val end = System.currentTimeMillis() + durationSec * 500L
val t0 = System.nanoTime()
val end = System.currentTimeMillis() + phaseMs
val inp = s.getInputStream()
while (System.currentTimeMillis() < end) {
val n = inp.read(buf); if (n < 0) break; downBytes += n
try {
while (System.currentTimeMillis() < end) {
val n = inp.read(buf); if (n < 0) break; downBytes += n
}
} catch (_: Exception) {
// s.o.
}
downSecs = (System.nanoTime() - t0) / 1_000_000_000.0
}
val secs = durationSec / 2.0
val down = if (downSecs > 0.1 && downBytes > 0) downBytes * 8.0 / 1_000_000.0 / downSecs else 0.0
val up = if (upSecs > 0.1 && upBytes > 0) upBytes * 8.0 / 1_000_000.0 / upSecs else 0.0
return JSObject()
.put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs))
.put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs))
.put("downMbps", round1(down))
.put("upMbps", round1(up))
.put("downBytes", downBytes)
.put("upBytes", upBytes)
// Ohne uebertragene Bytes gab es keine Messung — der Aufrufer darf
// daraus keine "0 Mbit/s"-Bewertung machen.
.put("measured", downBytes > 0 || upBytes > 0)
}
/* --------------------------------------------------------------------- */
@ -799,13 +1064,23 @@ class NetDiagScannerPlugin : Plugin() {
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
// gestartet werden, sonst kann Android den Prozess beenden.
io.launch {
val end = System.currentTimeMillis() + durationSec * 1000L
while (System.currentTimeMillis() < end && run.active) {
val q = measurePing(host, 5)
run.samples++
run.lossSum += q.getInteger("lossPct", 0) ?: 0
run.avgSum += q.getDouble("avgMs")
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
// Funk/CPU wachhalten. Ersetzt den Foreground-Service NICHT — bei
// ausgeschaltetem Display kann Android den Lauf weiterhin einfrieren.
// Der richtige Umbau steht in ROADMAP_UMSETZUNG.md Phase 2.
val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L)
try {
val end = System.currentTimeMillis() + durationSec * 1000L
while (System.currentTimeMillis() < end && run.active) {
val q = measurePing(host, 5)
run.samples++
// lossPct ist seit der Ping-Korrektur eine Kommazahl —
// getInteger() haette hier still 0 geliefert.
run.lossSum += q.getDouble("lossPct")
run.avgSum += q.getDouble("avgMs")
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
}
} finally {
releaseRadioLocks(locks)
}
}
resolve(call, JSObject().put("runId", runId))
@ -825,9 +1100,9 @@ class NetDiagScannerPlugin : Plugin() {
}
private class StressRun(val host: String, val durationSec: Int) {
var active = true
@Volatile var active = true
var samples = 0
var lossSum = 0
var lossSum = 0.0
var avgSum = 0.0
var maxMs = 0.0
}
@ -1398,15 +1673,36 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIp(i: Int): String =
"${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}"
/**
* Erste brauchbare lokale IPv4-Adresse oder "" wenn es keine gibt.
*
* Frueher stand hier `return "192.168.1.1"` als Fallback. Zusammen mit dem
* Praefix-Fallback /24 entstand daraus das Phantom-Netz 192.168.1.0/24: die
* App hat munter ein Netz gescannt, das gar nicht existierte, und den Wert
* anschliessend ins Protokoll geschrieben. Der dafuer vorgesehene Fehlerzweig
* in ipscan.ts ("Kein Netzbereich — WLAN/LAN nicht aktiv?") konnte deshalb nie
* greifen. Kein Netz zu haben ist ein Fehler und muss auch so gemeldet werden.
*
* Link-Local (169.254.x.x) wird uebersprungen: das ist eine Selbstvergabe
* mangels DHCP und taugt nicht als Scan-Grundlage.
*/
private fun firstLocalIpv4(): String {
java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni ->
ni.inetAddresses.toList().forEach { addr ->
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
return addr.hostAddress ?: ""
try {
java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni ->
if (!ni.isUp || ni.isLoopback) return@forEach
ni.inetAddresses.toList().forEach { addr ->
if (!addr.isLoopbackAddress &&
!addr.isLinkLocalAddress &&
addr is java.net.Inet4Address
) {
return addr.hostAddress ?: ""
}
}
}
} catch (e: Exception) {
android.util.Log.w(TAG, "firstLocalIpv4: ${e.message}")
}
return "192.168.1.1"
return ""
}
/** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */
@ -1428,23 +1724,87 @@ class NetDiagScannerPlugin : Plugin() {
return map
}
/** ping mit fester TTL -> (antwortende IP, Latenz in ms) */
private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean)
/**
* Ergebnis einer TTL-Sonde.
*
* @param ip antwortende IP oder "*" wenn niemand geantwortet hat
* @param ms Laufzeit in ms, oder null wenn nicht messbar
* @param reachedTarget true wenn das Ziel selbst geantwortet hat (Echo Reply)
*/
private data class Hop(val ip: String, val ms: Double?, val reachedTarget: Boolean)
/**
* ping mit fester TTL -> antwortender Hop.
*
* Frühere Fassung hatte zwei Fehler, die zusammen ganze Routen erfunden haben:
*
* 1. Fällt der Regex `From ([\d.]+)` durch (stummer Hop, oder ein Router, der
* mit Reverse-DNS antwortet: "From fritz.box (192.168.1.1)"), griff der
* Fallback `\((\d+\.\d+\.\d+\.\d+)\)`. Der trifft aber IMMER zuerst die
* Kopfzeile `PING 8.8.8.8 (8.8.8.8) 56 data bytes` ein Hop, der gar nicht
* geantwortet hat, wurde damit als Ziel ausgegeben und der Trace beendet.
* Das ist die Ursache für Prod-Messung #13 ("2 Hops bis 8.8.8.8").
* 2. Zwischen-Hops liefern grundsätzlich kein `time=` (das gibt es nur beim
* Echo Reply des Ziels) -> `ms` war strukturell 0.0.
*
* Jetzt: Kopfzeile wird verworfen, zeilenweise ausgewertet, die Laufzeit selbst
* gestoppt, und der Prozess bekommt eine harte Zeitgrenze (vorher konnte
* `waitFor()` unbegrenzt hängen).
*/
private fun pingWithTtl(host: String, ttl: Int): Hop {
var proc: Process? = null
return try {
val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host)
.redirectErrorStream(true).start()
val t0 = System.nanoTime()
proc = ProcessBuilder(
"/system/bin/ping", "-n", "-c", "1", "-W", "2", "-t", ttl.toString(), host,
).redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText()
proc.waitFor()
val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1)
?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1)
?: "*"
val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0
val reached = out.contains("bytes from")
Hop(if (reached) host else ip, ms, reached)
if (!proc.waitFor(3, TimeUnit.SECONDS)) {
proc.destroyForcibly()
return Hop("*", null, false)
}
val elapsedMs = (System.nanoTime() - t0) / 1_000_000.0
var ip = ""
var reached = false
var reportedMs: Double? = null
for (raw in out.lineSequence()) {
val line = raw.trim()
// Kopfzeile "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data." NIE auswerten
if (line.startsWith("PING ")) continue
// Ziel selbst: "64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=12.3 ms"
if (line.contains("bytes from")) {
reached = true
if (ip.isEmpty()) {
ip = Regex("""bytes from ([\d.]+)""").find(line)?.groupValues?.get(1) ?: ""
}
reportedMs = Regex("""time[=<]([\d.]+)""").find(line)
?.groupValues?.get(1)?.toDoubleOrNull()
break
}
// Zwischen-Hop: "From 192.168.1.1 icmp_seq=1 Time to live exceeded"
// oder mit Namen: "From fritz.box (192.168.1.1) icmp_seq=1 ..."
if (line.startsWith("From ")) {
val m = Regex("""From (?:\S+ )?\(?(\d+\.\d+\.\d+\.\d+)\)?""").find(line)
if (m != null && ip.isEmpty()) ip = m.groupValues[1]
}
}
when {
// Echo Reply: bevorzugt die vom ping gemeldete Zeit, sonst die selbst gestoppte
reached -> Hop(if (ip.isNotEmpty()) ip else host, reportedMs ?: round1(elapsedMs), true)
// Antwortender Zwischen-Hop: ping meldet keine Zeit -> selbst gestoppte nutzen
ip.isNotEmpty() -> Hop(ip, round1(elapsedMs), false)
// Niemand hat geantwortet
else -> Hop("*", null, false)
}
} catch (e: Exception) {
Hop("*", 0.0, false)
try { proc?.destroyForcibly() } catch (_: Exception) { }
Hop("*", null, false)
}
}
@ -1471,6 +1831,14 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object {
private const val TAG = "NetDiagScanner"
/** Lese-/Schreib-Zeitgrenze auf Mess-Sockets. Ohne sie blockiert read() unbegrenzt. */
private const val SOCKET_IO_TIMEOUT_MS = 4000
/** Proben, die vor einer Ping-Messung verworfen werden (Funkmodul aufwecken). */
private const val PING_WARMUP_PROBES = 2
/**
* Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf

View file

@ -4,34 +4,77 @@ import java.io.ByteArrayOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketTimeoutException
/**
* Minimaler SNMP-v2c-GET-Client.
*
* Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von
* gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig.
*
* WICHTIG warum das Ergebnis ein eigener Typ ist und kein `String?`:
* Früher lieferte diese Klasse bei JEDEM Problem `null`; der Aufrufer machte
* daraus `"-"`, `parseInt("-") || 0` ergab `0` und das Werkzeug meldete
* 0 Fehler" in Grün — obwohl der Switch überhaupt nicht geantwortet hatte.
* Genau so steht es in den Prod-Messungen #95/#96. Ein nicht erreichbarer Switch
* darf nie wie ein fehlerfreier Switch aussehen, deshalb wird der Grund jetzt
* mitgeliefert und der Aufrufer muss ihn behandeln.
*/
object Snmp {
/** Ergebnis einer OID-Abfrage — Wert, Zeitüberschreitung oder Fehler mit Grund. */
sealed class Result {
/** Der Agent hat geantwortet und einen Wert geliefert. */
data class Value(val text: String) : Result()
/** Keine Antwort in der vorgegebenen Zeit (kein Agent, Firewall, Gerät aus). */
object Timeout : Result()
/** Agent hat geantwortet, aber keinen brauchbaren Wert geliefert. */
data class Failure(val reason: String) : Result()
}
/**
* Eine OID per SNMP v2c GET abfragen.
*
* @return Wert als String oder null bei Fehler/Timeout
* @param timeoutMs Wartezeit je Versuch
* @param attempts Anzahl Versuche (UDP darf ruhig einmal verloren gehen)
*/
fun get(host: String, community: String, oid: String): String? {
fun get(
host: String,
community: String,
oid: String,
timeoutMs: Int = 1500,
attempts: Int = 2,
): Result {
var last: Result = Result.Timeout
repeat(attempts) {
last = getOnce(host, community, oid, timeoutMs)
// Nur bei Zeitüberschreitung erneut versuchen — eine inhaltliche
// Absage (falsche OID, error-status) wiederholt sich sowieso.
if (last !is Result.Timeout) return last
}
return last
}
private fun getOnce(host: String, community: String, oid: String, timeoutMs: Int): Result {
// Request-ID zufällig, damit eine verspätete Antwort auf eine frühere
// Abfrage nicht als Antwort auf diese hier durchgeht.
val requestId = (1..0x7FFFFFFE).random()
return try {
val request = buildGetRequest(community, oid)
val socket = DatagramSocket()
socket.soTimeout = 2500
socket.use {
it.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
val request = buildGetRequest(community, oid, requestId)
DatagramSocket().use { sock ->
sock.soTimeout = timeoutMs
sock.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
val buf = ByteArray(2048)
val resp = DatagramPacket(buf, buf.size)
it.receive(resp)
parseFirstValue(buf, resp.length)
sock.receive(resp)
parseResponse(buf, resp.length, requestId)
}
} catch (e: SocketTimeoutException) {
Result.Timeout
} catch (e: Exception) {
null
Result.Failure(e.message ?: "SNMP-Fehler")
}
}
@ -80,59 +123,143 @@ object Snmp {
return tlv(0x06, out.toByteArray())
}
private fun buildGetRequest(community: String, oidStr: String): ByteArray {
private fun buildGetRequest(community: String, oidStr: String, requestId: Int): ByteArray {
val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL
val varbindList = tlv(0x30, varbind)
val requestId = (System.currentTimeMillis() and 0x7FFF).toInt()
val pdu = tlv(
0xA0, // GetRequest-PDU
integer(requestId) + integer(0) + integer(0) + varbindList,
)
val message = tlv(
return tlv(
0x30,
integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c
)
return message
}
/* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */
/* ---- Antwort parsen ---- */
private fun parseFirstValue(buf: ByteArray, len: Int): String? {
/** Kleiner BER-Leser mit Bereichsprüfung. */
private class Reader(val buf: ByteArray, val len: Int) {
var i = 0
// Durch die Struktur navigieren bis zum ersten primitiven Wert nach einer OID
var lastWasOid = false
while (i < len) {
val tag = buf[i].toInt() and 0xFF
i++
if (i >= len) break
var l = buf[i].toInt() and 0xFF
i++
fun tag(): Int {
require(i < len) { "unerwartetes Paketende" }
return (buf[i++].toInt() and 0xFF)
}
fun length(): Int {
require(i < len) { "unerwartetes Paketende" }
var l = buf[i++].toInt() and 0xFF
if (l and 0x80 != 0) {
val n = l and 0x7F
require(n in 1..4 && i + n <= len) { "ungueltige Laenge" }
l = 0
for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ }
for (k in 0 until n) l = (l shl 8) or (buf[i++].toInt() and 0xFF)
}
// Konstruierte Typen (SEQUENCE, PDU) aufsteigen
if (tag == 0x30 || tag == 0xA2 || tag == 0xA0) continue
if (tag == 0x06) { lastWasOid = true; i += l; continue }
if (lastWasOid) {
return when (tag) {
0x02, 0x41, 0x42, 0x43, 0x44, 0x46 -> { // INTEGER, Counter, Gauge, TimeTicks ...
var v = 0L
for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF)
v.toString()
}
0x04 -> String(buf, i, l) // OCTET STRING
0x05 -> "" // NULL -> kein Wert
else -> {
val sb = StringBuilder()
for (k in 0 until l) sb.append("%02X".format(buf[i + k]))
sb.toString()
}
}
}
i += l
require(l >= 0 && i + l <= len) { "Laenge zeigt ueber das Paket hinaus" }
return l
}
return null
fun intValue(l: Int): Long {
var v = 0L
for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF)
i += l
return v
}
fun text(l: Int): String {
val s = String(buf, i, l)
i += l
return s
}
fun hex(l: Int): String {
val sb = StringBuilder()
for (k in 0 until l) sb.append("%02X".format(buf[i + k]))
i += l
return sb.toString()
}
fun ipv4(l: Int): String {
val s = (0 until l).joinToString(".") { (buf[i + it].toInt() and 0xFF).toString() }
i += l
return s
}
fun skip(l: Int) { i += l }
}
/**
* Die Antwortstruktur wird gezielt durchlaufen statt heuristisch nach dem
* ersten primitiven Wert gesucht nur so lassen sich Request-ID und
* error-status überhaupt prüfen.
*
* SEQUENCE { version, community, GetResponse[0xA2] { req-id, err-status,
* err-index, SEQUENCE { SEQUENCE { OID, Wert } } } }
*/
private fun parseResponse(buf: ByteArray, len: Int, expectedRequestId: Int): Result {
return try {
val r = Reader(buf, len)
if (r.tag() != 0x30) return Result.Failure("kein SNMP-Paket")
r.length()
if (r.tag() != 0x02) return Result.Failure("Version fehlt")
r.skip(r.length())
if (r.tag() != 0x04) return Result.Failure("Community fehlt")
r.skip(r.length())
val pduTag = r.tag()
if (pduTag != 0xA2) return Result.Failure("keine GetResponse (Tag 0x%02X)".format(pduTag))
r.length()
if (r.tag() != 0x02) return Result.Failure("Request-ID fehlt")
val reqId = r.intValue(r.length())
if (reqId != expectedRequestId.toLong()) {
return Result.Failure("Antwort gehoert zu einer anderen Abfrage")
}
if (r.tag() != 0x02) return Result.Failure("error-status fehlt")
val errStatus = r.intValue(r.length())
if (errStatus != 0L) return Result.Failure(errorStatusText(errStatus.toInt()))
if (r.tag() != 0x02) return Result.Failure("error-index fehlt")
r.skip(r.length())
if (r.tag() != 0x30) return Result.Failure("Variablenliste fehlt")
r.length()
if (r.tag() != 0x30) return Result.Failure("Variable fehlt")
r.length()
if (r.tag() != 0x06) return Result.Failure("OID fehlt")
r.skip(r.length())
val valueTag = r.tag()
val valueLen = r.length()
when (valueTag) {
// INTEGER, Counter32, Gauge32, TimeTicks, Counter64
0x02, 0x41, 0x42, 0x43, 0x46 -> Result.Value(r.intValue(valueLen).toString())
0x04 -> Result.Value(r.text(valueLen)) // OCTET STRING
0x40 -> Result.Value(r.ipv4(valueLen)) // IpAddress
0x05 -> Result.Failure("Agent liefert keinen Wert (NULL)")
// v2c-Ausnahmen: der Agent kennt die OID nicht. Fruehler kam hier
// ein Leerstring heraus, der als 0 weiterverarbeitet wurde.
0x80 -> Result.Failure("OID auf diesem Geraet nicht vorhanden")
0x81 -> Result.Failure("OID-Instanz nicht vorhanden (Port-Index falsch?)")
0x82 -> Result.Failure("Ende der MIB erreicht")
else -> Result.Value(r.hex(valueLen))
}
} catch (e: Exception) {
Result.Failure("Antwort nicht lesbar: ${e.message}")
}
}
/** error-status aus RFC 3416 in Klartext. */
private fun errorStatusText(code: Int): String = when (code) {
1 -> "Antwort zu gross (tooBig)"
2 -> "OID unbekannt (noSuchName)"
3 -> "Wert passt nicht (badValue)"
4 -> "nur lesbar (readOnly)"
5 -> "allgemeiner Agent-Fehler (genErr)"
6 -> "Zugriff verweigert — Community pruefen (noAccess)"
else -> "Agent meldet Fehler $code"
}
}

View file

@ -36,3 +36,10 @@ body {
.ampel-fail {
color: #f85149;
}
/*
* Nicht messbar" bewusst neutral-grau statt rot. Der Test konnte nicht
* durchgeführt werden; das ist keine Aussage über das Kundennetz.
*/
.ampel-unmess {
color: #8b949e;
}

View file

@ -29,8 +29,9 @@
onrename?: () => void;
} = $props();
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived(

View file

@ -65,11 +65,18 @@ export interface OpenPort {
export interface PingQuality {
sent: number;
received: number;
/** Verlust in Prozent (Kommazahl — 1 von 20 Paketen = 5,0) */
lossPct: number;
minMs: number;
avgMs: number;
/** Median der Laufzeiten — robuster gegen einzelne Ausreisser als avgMs */
medianMs: number;
maxMs: number;
jitterMs: number;
/** avg - min: Anteil, der vom Messverfahren stammt und nicht vom Netz */
messaufschlagMs: number;
/** Klartext des benutzten Messverfahrens (fuer das Protokoll) */
verfahren: string;
}
export interface WifiNetwork {
ssid: string;
@ -90,20 +97,44 @@ export interface DhcpLease {
}
export interface TracerouteHop {
ttl: number;
/** antwortende IP oder '*' wenn niemand geantwortet hat */
ip: string;
ms: number;
/** Mittelwert der Proben in ms, null wenn keine Antwort kam (NICHT 0) */
ms: number | null;
minMs?: number;
/** wie viele der 3 Proben geantwortet haben */
antworten?: number;
}
export interface ThroughputResult {
downMbps: number;
upMbps: number;
downBytes?: number;
upBytes?: number;
/** false = es wurde kein einziges Byte uebertragen, also keine Messung */
measured?: boolean;
}
/** Schnittstelle des nativen Plugins */
export interface NetDiagScannerPlugin {
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
getLocalSubnet(): Promise<{
subnet: string;
ip: string;
gateway: string;
prefix?: number;
/** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */
prefixQuelle?: string;
}>;
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
ipScan(opts: { subnet: string }): Promise<{
devices: ScannedDevice[];
/** wie viele Adressen geprueft wurden */
probed?: number;
/** wie viele davon geantwortet haben */
answered?: number;
/** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */
arpAvailable?: boolean;
}>;
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
@ -123,9 +154,17 @@ export interface NetDiagScannerPlugin {
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{
values: Record<string, string>;
/** false = der Switch hat ueberhaupt nicht geantwortet */
answered: boolean;
/** Klartextgrund, wenn etwas nicht gelesen werden konnte */
error: string;
}>;
/** Traceroute zu einem Host */
traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>;
traceroute(opts: { host: string }): Promise<{
hops: TracerouteHop[];
/** hat das Ziel selbst geantwortet? */
reachedTarget: boolean;
}>;
/** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */
throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>;
/** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */
@ -197,7 +236,13 @@ let mockWifiTrackRssi = -55;
const mock: NetDiagScannerPlugin = {
async getLocalSubnet() {
return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' };
return {
subnet: '192.168.1.0/24',
ip: '192.168.1.50',
gateway: '192.168.1.1',
prefix: 24,
prefixQuelle: 'adapter',
};
},
async ipScan() {
return {
@ -274,14 +319,19 @@ const mock: NetDiagScannerPlugin = {
async pingQuality(opts) {
const sent = opts.count;
const received = sent - (Math.random() < 0.3 ? 1 : 0);
const min = rnd(1, 4);
const avg = rnd(4, 12);
return {
sent,
received,
lossPct: Math.round(((sent - received) / sent) * 100),
minMs: rnd(1, 4),
avgMs: rnd(4, 12),
lossPct: Math.round(((sent - received) / sent) * 1000) / 10,
minMs: min,
avgMs: avg,
medianMs: rnd(min, avg),
maxMs: rnd(12, 40),
jitterMs: rnd(0.5, 5),
messaufschlagMs: Math.round((avg - min) * 10) / 10,
verfahren: 'Mock (Browser-Entwicklung)',
};
},
async wifiScan() {
@ -304,19 +354,26 @@ const mock: NetDiagScannerPlugin = {
async snmpGet(opts) {
const values: Record<string, string> = {};
for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000));
return { values };
return { values, answered: true, error: '' };
},
async traceroute() {
return {
hops: [
{ ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3) },
{ ttl: 2, ip: '10.0.0.1', ms: rnd(8, 15) },
{ ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30) },
{ ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3), antworten: 3 },
{ ttl: 2, ip: '*', ms: null, antworten: 0 },
{ ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30), antworten: 3 },
],
reachedTarget: true,
};
},
async throughput() {
return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) };
return {
downMbps: rnd(80, 940),
upMbps: rnd(40, 500),
downBytes: 12_000_000,
upBytes: 6_000_000,
measured: true,
};
},
async startStressTest() {
return { runId: 'mock-run' };

View file

@ -42,6 +42,23 @@ export const iperfTool: Tool = {
const res = await scanner.throughput({ host, port, durationSec });
// Kein einziges Byte übertragen = es gab keine Messung. Daraus „0 Mbit/s"
// in Rot zu machen wäre eine Aussage über das Kundennetz, die die Messung
// gar nicht hergibt — meist fehlt schlicht die Gegenstelle.
if (res.measured === false) {
return {
label: `${host}:${port} — keine Gegenstelle`,
result: {
gegenstelle: `${host}:${port}`,
hinweis:
'Es wurden keine Daten übertragen. Der Test braucht eine Gegenstelle, ' +
'die auf diesem Port Daten annimmt und sendet. Ein echter iperf3-Server ' +
'funktioniert hier NICHT (siehe Modul-Doku).',
},
measureStatus: 3, // nicht messbar
};
}
let status: MeasureStatus = 0;
if (res.downMbps < 100) status = 1;
if (res.downMbps < 10) status = 2;

View file

@ -23,22 +23,46 @@ export const pingTool: Tool = {
const q = await scanner.pingQuality({ host, count });
// Bewertung: Verlust und Jitter
// Gar keine Antwort: das Ziel ist nicht erreichbar. Latenzwerte gibt es
// dann nicht — 0 ms auszugeben wäre irreführend.
if (q.received === 0) {
return {
label: `${host}: keine Antwort`,
result: {
host,
gesendet: q.sent,
empfangen: 0,
verlustProzent: 100,
hinweis: 'Ziel antwortet nicht. Falsche Adresse, Gerät aus oder Firewall?',
},
measureStatus: 2,
};
}
// Bewertung auf den Median statt den Mittelwert: ein einzelner Ausreißer
// (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der
// Median bleibt bei dem, was das Netz üblicherweise liefert.
const leitwert = q.medianMs ?? q.avgMs;
let status: MeasureStatus = 0;
if (q.lossPct > 0 || q.jitterMs > 10) status = 1;
if (q.lossPct >= 10 || q.avgMs > 100) status = 2;
if (q.lossPct >= 10 || leitwert > 100) status = 2;
return {
label: `${host}: ${q.avgMs} ms ø, ${q.lossPct}% Verlust`,
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,
result: {
host,
gesendet: q.sent,
empfangen: q.received,
verlustProzent: q.lossPct,
minMs: q.minMs,
medianMs: q.medianMs,
avgMs: q.avgMs,
maxMs: q.maxMs,
jitterMs: q.jitterMs,
// Macht sichtbar, wie viel Messverfahren im Wert steckt: ein großer
// Abstand zwischen Mittelwert und Minimum kommt vom Messweg, nicht vom Netz.
messaufschlagMs: q.messaufschlagMs,
verfahren: q.verfahren,
},
measureStatus: status,
};

View file

@ -32,26 +32,81 @@ export const snmpTool: Tool = {
if (!host) throw new Error('Keine Switch-IP angegeben');
const community = String(ctx.params.community || 'public');
const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) });
const { values, answered, error } = await scanner.snmpGet({
host,
community,
oids: Object.values(OIDS),
});
// OID-Werte den lesbaren Namen zuordnen
const named: Record<string, string> = {};
for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-';
// Der Switch hat überhaupt nicht geantwortet -> keine Messung.
// Früher wurde daraus über parseInt('-') || 0 ein grünes „0 Fehler"
// (siehe Prod-Messungen #95/#96) — ein Totalausfall sah damit aus wie ein
// fehlerfreier Switch.
if (!answered) {
return {
label: `${host}: keine SNMP-Antwort`,
result: {
host,
hinweis:
error ||
'Keine Antwort auf Port 161. Mögliche Ursachen: SNMP am Gerät nicht ' +
'aktiviert, andere Community, SNMPv3 statt v2c, oder Firewall.',
},
measureStatus: 3, // nicht messbar — keine Aussage über das Netz
};
}
const inErr = parseInt(named['ifInErrors'], 10) || 0;
const outErr = parseInt(named['ifOutErrors'], 10) || 0;
// Zahlenwerte nur übernehmen, wenn sie wirklich geliefert wurden
const num = (key: string): number | null => {
const raw = values[OIDS[key]];
if (raw === undefined || raw === null || raw === '') return null;
const n = Number.parseInt(raw, 10);
return Number.isNaN(n) ? null : n;
};
const text = (key: string): string | null => {
const raw = values[OIDS[key]];
return raw === undefined || raw === '' ? null : raw;
};
const inErr = num('ifInErrors');
const outErr = num('ifOutErrors');
// Agent erreichbar, aber die Fehlerzähler gibt es nicht (falscher Port-Index,
// eingeschränkte MIB-Sicht). Auch das ist „nicht messbar", nicht „0 Fehler".
if (inErr === null && outErr === null) {
return {
label: `${host}: Fehlerzähler nicht lesbar`,
result: {
host,
port: text('ifDescr'),
linkSpeed: text('ifSpeed'),
hinweis:
error ||
'Der Switch antwortet, liefert für diesen Port-Index aber keine ' +
'Fehlerzähler. Standardmäßig wird Interface-Index 1 abgefragt — das ist ' +
'oft nicht der gesuchte Port.',
},
measureStatus: 3,
};
}
const sum = (inErr ?? 0) + (outErr ?? 0);
let status: MeasureStatus = 0;
if (inErr + outErr > 0) status = 1;
if (inErr + outErr > 100) status = 2;
if (sum > 0) status = 1;
if (sum > 100) status = 2;
const speedBits = num('ifSpeed');
return {
label: `${host}: ${inErr + outErr} Fehler`,
label: `${host}: ${sum} Fehler`,
result: {
host,
port: named['ifDescr'],
linkSpeed: named['ifSpeed'],
port: text('ifDescr'),
linkSpeed: speedBits !== null ? `${Math.round(speedBits / 1_000_000)} Mbit/s` : null,
eingangsFehler: inErr,
ausgangsFehler: outErr,
hinweis:
'Absolute Zähler seit dem letzten Neustart des Switches — sie sagen nichts ' +
'darüber, ob die Fehler heute oder vor zwei Jahren aufgetreten sind.',
},
measureStatus: status,
};

View file

@ -23,7 +23,16 @@ export const stressTestTool: Tool = {
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
scope: 'protocol',
params: [
{ key: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' },
// KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im
// Kundennetz 192.168.178.0/24 fünf Minuten lang ins Leere gemessen und das
// Ergebnis als „100 % Verlust" rot ins Protokoll geschrieben (Prod #126).
// Leer lassen -> es wird das Gateway des aktiven Adapters genommen.
{
key: 'host',
label: 'Ziel — leer = Gateway des aktiven Netzes',
type: 'text',
placeholder: 'leer lassen → Gateway',
},
{
key: 'duration',
label: 'Dauer',
@ -38,9 +47,47 @@ export const stressTestTool: Tool = {
},
],
async run(ctx) {
const host = String(ctx.params.host || '192.168.1.1');
let host = String(ctx.params.host ?? '').trim();
const durationSec = Number(ctx.params.duration || 300);
// Kein Ziel angegeben -> Gateway des aktiven Adapters
if (!host) {
try {
host = String((await scanner.getLocalSubnet()).gateway ?? '').trim();
} catch {
/* unten abgefangen */
}
}
if (!host) {
return {
label: 'Kein Ziel — WLAN/LAN nicht aktiv?',
result: {
hinweis:
'Es konnte kein Gateway ermittelt werden. Bitte ein Ziel angeben ' +
'(z.B. den Router des Kundennetzes).',
},
measureStatus: 3,
};
}
// Vorlaufprobe: antwortet das Ziel überhaupt? Sonst misst man minuten- bis
// stundenlang gegen etwas, das gar nicht da ist, und bekommt am Ende
// „100 % Verlust" — was wie ein kaputtes Kundennetz aussieht.
const probe = await scanner.pingQuality({ host, count: 3 });
if (probe.received === 0) {
return {
label: `${host} antwortet nicht — Test nicht gestartet`,
result: {
host,
hinweis:
`${host} hat auf 3 Vorabproben nicht geantwortet. Ein Dauertest gegen ` +
'ein nicht erreichbares Ziel liefert nur 100 % Verlust und sagt nichts ' +
'über das Netz aus. Ziel prüfen und erneut starten.',
},
measureStatus: 3,
};
}
const { runId } = await scanner.startStressTest({ host, durationSec });
// Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von
// Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis

View file

@ -3,7 +3,7 @@
*/
import { scanner } from '../../scanner';
import type { Tool } from '../types';
import type { MeasureStatus, Tool } from '../types';
export const tracerouteTool: Tool = {
id: 'traceroute',
@ -15,14 +15,36 @@ export const tracerouteTool: Tool = {
params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }],
async run(ctx) {
const host = String(ctx.params.host || '8.8.8.8');
const { hops } = await scanner.traceroute({ host });
const { hops, reachedTarget } = await scanner.traceroute({ host });
const antwortend = hops.filter((h) => h.ip !== '*').length;
// Bewertung an „Ziel erreicht" hängen, nicht an „Liste nicht leer".
// Vorher war jede Ausgabe grün — auch eine Liste aus lauter Sternchen.
let status: MeasureStatus = 0;
if (!reachedTarget) status = 2;
if (antwortend === 0) status = 3; // gar keine Antwort -> nicht messbar
return {
label: `${hops.length} Hops bis ${host}`,
label: reachedTarget
? `${hops.length} Hops bis ${host}`
: antwortend === 0
? `${host}: keine Antwort auf dem Weg`
: `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
result: {
ziel: host,
hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`),
zielErreicht: reachedTarget,
hops: hops.map((h) =>
h.ip === '*'
? `${h.ttl}. * (keine Antwort)`
: `${h.ttl}. ${h.ip} (${h.ms === null || h.ms === undefined ? '—' : h.ms + ' ms'})`,
),
hinweis: reachedTarget
? undefined
: 'Viele Router antworten auf abgelaufene TTL grundsätzlich nicht — ' +
'Sternchen bedeuten nicht zwingend eine Störung.',
},
measureStatus: hops.length > 0 ? 0 : 2,
measureStatus: status,
};
},
};

View file

@ -163,7 +163,17 @@ export interface WifiTrackSession {
}
/** Ampel-Bewertung einer Messung */
export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail
/**
* Bewertung einer Messung.
*
* 3 ist bewusst kein schlechter als rot", sondern eine eigene Aussage:
* die Messung konnte gar nicht durchgeführt werden (Switch antwortet nicht
* auf SNMP, kein Netz, Gegenstelle fehlt). Ohne diesen Zustand wird ein
* fehlgeschlagener Test entweder als alles gut" (grün, weil 0 Fehler
* gezählt wurden) oder als Netzfehler (rot) protokolliert beides ist
* gegenüber dem Kunden falsch.
*/
export type MeasureStatus = 0 | 1 | 2 | 3; // 0=ok, 1=warn, 2=fail, 3=nicht messbar
/** Ergebnis eines Tool-Laufs */
export interface Measurement {

View file

@ -49,8 +49,9 @@
),
);
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
onMount(async () => {
const uuid = $page.params.id ?? '';