diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6378887..2fc9f39 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -51,6 +51,14 @@
+
+
diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt
index d9fa0c5..ec61424 100644
--- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt
+++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt
@@ -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()
+ /* --------------------------------------------------------------------- */
+ /* 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 {
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()
- 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()
+ 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()
+ 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
diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt b/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt
index 3154dd4..61e66c2 100644
--- a/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt
+++ b/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt
@@ -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"
}
}
diff --git a/src/app.css b/src/app.css
index d24a712..72f7d78 100644
--- a/src/app.css
+++ b/src/app.css
@@ -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;
+}
diff --git a/src/lib/components/DeviceCard.svelte b/src/lib/components/DeviceCard.svelte
index 2d44c5f..a1c64b2 100644
--- a/src/lib/components/DeviceCard.svelte
+++ b/src/lib/components/DeviceCard.svelte
@@ -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(
diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts
index 1782b13..7a1d1e2 100644
--- a/src/lib/scanner.ts
+++ b/src/lib/scanner.ts
@@ -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;
+ /** 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;
/** 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 = {};
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' };
diff --git a/src/lib/tools/internet/iperf.ts b/src/lib/tools/internet/iperf.ts
index 614829c..aaf47bd 100644
--- a/src/lib/tools/internet/iperf.ts
+++ b/src/lib/tools/internet/iperf.ts
@@ -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;
diff --git a/src/lib/tools/netzwerk/ping.ts b/src/lib/tools/netzwerk/ping.ts
index e448cff..f6c0863 100644
--- a/src/lib/tools/netzwerk/ping.ts
+++ b/src/lib/tools/netzwerk/ping.ts
@@ -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,
};
diff --git a/src/lib/tools/netzwerk/snmp.ts b/src/lib/tools/netzwerk/snmp.ts
index 0965306..ab48e0d 100644
--- a/src/lib/tools/netzwerk/snmp.ts
+++ b/src/lib/tools/netzwerk/snmp.ts
@@ -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 = {};
- 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,
};
diff --git a/src/lib/tools/netzwerk/stresstest.ts b/src/lib/tools/netzwerk/stresstest.ts
index 5351197..8cbbff0 100644
--- a/src/lib/tools/netzwerk/stresstest.ts
+++ b/src/lib/tools/netzwerk/stresstest.ts
@@ -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
diff --git a/src/lib/tools/netzwerk/traceroute.ts b/src/lib/tools/netzwerk/traceroute.ts
index b7c0708..5d264ed 100644
--- a/src/lib/tools/netzwerk/traceroute.ts
+++ b/src/lib/tools/netzwerk/traceroute.ts
@@ -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,
};
},
};
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ecb1eb8..fc48705 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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 {
diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte
index eea0e88..48e0695 100644
--- a/src/routes/protokoll/[id]/+page.svelte
+++ b/src/routes/protokoll/[id]/+page.svelte
@@ -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 ?? '';