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 6fb14dd..d9fa0c5 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 @@ -2,9 +2,14 @@ package de.data_it_solution.netdiag import android.Manifest import android.app.NotificationManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities import android.net.Uri import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo @@ -961,6 +966,339 @@ class NetDiagScannerPlugin : Plugin() { } catch (_: Exception) { } } + /* --------------------------------------------------------------------- */ + /* IP-Test — alle aktiven Netzwerk-Interfaces (WLAN, Ethernet, USB-RJ45) */ + /* --------------------------------------------------------------------- */ + + /** + * Listet alle aktiven Netzwerk-Interfaces des Geräts. Für jedes Interface + * werden IP, Präfix, Gateway, DNS, DHCP-Server (nur WLAN) und Link-Speed + * geliefert. Bei USB-RJ45-Adaptern wird die Speed aus + * /sys/class/net//speed gelesen — funktioniert ohne Root. + * + * Anwendungsfall: Adapter in eine Netzwerkdose stecken und sofort sehen, + * ob eine IP kommt und ob der Link 10/100/1000 Mbit ausspuckt. + */ + @PluginMethod + fun linkInfo(call: PluginCall) { + io.launch { + try { + val cm = context.applicationContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + val defaultIface = cm.activeNetwork?.let { cm.getLinkProperties(it)?.interfaceName } + + val arr = JSArray() + val networks: Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) cm.allNetworks + else emptyArray() + for (net in networks) { + val caps = cm.getNetworkCapabilities(net) ?: continue + val lp = cm.getLinkProperties(net) ?: continue + val iface = lp.interfaceName ?: continue + + val type = when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + // IPv4 + Präfix aus den LinkAddresses + var ipv4: String? = null + var prefix = 0 + for (la in lp.linkAddresses) { + val a = la.address + if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { + ipv4 = a.hostAddress + prefix = la.prefixLength + break + } + } + var gateway: String? = null + for (route in lp.routes) { + val gw = route.gateway + if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { + gateway = gw.hostAddress + break + } + } + val dnsArr = JSArray() + for (d in lp.dnsServers) { + if (d is Inet4Address) d.hostAddress?.let { dnsArr.put(it) } + } + + val obj = JSObject() + .put("iface", iface) + .put("type", type) + .put("isDefault", iface == defaultIface) + if (ipv4 != null) obj.put("ipv4", ipv4) + if (prefix > 0) obj.put("prefixLength", prefix) + if (gateway != null) obj.put("gateway", gateway) + obj.put("dns", dnsArr) + + // USB-RJ45-Heuristik + val ifLower = iface.lowercase() + val isUsb = ifLower.startsWith("rndis") || + ifLower.startsWith("usb") || + ifLower.startsWith("ecm") || + (type == "ethernet" && ifLower.matches(Regex("eth[1-9].*"))) + if (isUsb) obj.put("isUsbEthernet", true) + + // Link-Speed-Quellen + if (type == "wifi") { + try { + @Suppress("DEPRECATION") val ci = wifi.connectionInfo + if (ci != null) { + if (ci.linkSpeed > 0) obj.put("linkSpeedMbps", ci.linkSpeed) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ci.rxLinkSpeedMbps > 0) { + obj.put("rxLinkSpeedMbps", ci.rxLinkSpeedMbps) + } + val ssid = ci.ssid?.trim('"') + if (!ssid.isNullOrEmpty() && ssid != "") { + obj.put("ssid", ssid) + } + if (!ci.bssid.isNullOrEmpty()) obj.put("bssid", ci.bssid) + if (ci.rssi != -127) obj.put("rssi", ci.rssi) + } + @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo + if (dhcp != null && dhcp.serverAddress != 0) { + obj.put("dhcpServer", intToIp(dhcp.serverAddress)) + if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration) + } + } catch (_: Exception) { /* WLAN-Info nicht greifbar */ } + } else if (type == "ethernet") { + val mbps = readEthernetSpeed(iface) + if (mbps != null) obj.put("linkSpeedMbps", mbps) + } + arr.put(obj) + } + resolve(call, JSObject().put("links", arr)) + } catch (e: Exception) { + call.reject("linkInfo: ${e.message}") + } + } + } + + /** + * Link-Speed eines Ethernet-Interfaces aus /sys/class/net//speed lesen. + * Liefert Mbit/s als Int (z.B. 1000 für Gigabit), oder null wenn nicht lesbar + * (auf manchen Android-12+-Geräten ist /sys/class/net/ selektiv gesperrt). + */ + private fun readEthernetSpeed(iface: String): Int? { + return try { + val f = File("/sys/class/net/$iface/speed") + if (!f.exists() || !f.canRead()) return null + val raw = f.readText().trim() + val n = raw.toIntOrNull() ?: return null + if (n <= 0) null else n + } catch (_: Exception) { + null + } + } + + /* --------------------------------------------------------------------- */ + /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ + /* --------------------------------------------------------------------- */ + + private val wifiTrackRuns = ConcurrentHashMap() + + private class WifiTrackRun( + val bssid: String, + val mode: String, // "connected" | "scan" + val intervalMs: Int, + ) { + @Volatile var active = true + val samples = java.util.concurrent.CopyOnWriteArrayList() + var scanReceiver: BroadcastReceiver? = null + } + + /** + * Aktiven WLAN-Scan triggern (System legt frische Ergebnisse in den Cache). + * Liefert `triggered=false` bei Android-Throttling (ab API 28: max. 4 + * Foreground-Scans / 2 Min). + */ + @PluginMethod + fun startWifiScan(call: PluginCall) { + if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "startWifiScanPermCallback") + return + } + doStartWifiScan(call) + } + + @PermissionCallback + private fun startWifiScanPermCallback(call: PluginCall) { + if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { + doStartWifiScan(call) + } else { + call.reject("Standortberechtigung für WLAN-Scan abgelehnt") + } + } + + private fun doStartWifiScan(call: PluginCall) { + try { + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") val triggered = wifi.startScan() + resolve(call, JSObject().put("triggered", triggered)) + } catch (e: Exception) { + call.reject("startWifiScan: ${e.message}") + } + } + + /** + * Empfangs-Tracker für ein BSSID starten. Wenn das BSSID das aktuell + * verbundene Netz ist: Live-RSSI aus `WifiManager.connectionInfo` alle + * `intervalMs` (kein Scan-Throttling). Sonst: periodischer `startScan` + * alle ~30 s, BroadcastReceiver lauscht auf `SCAN_RESULTS_AVAILABLE_ACTION` + * und liefert den Wert des passenden BSSIDs. + */ + @PluginMethod + fun startWifiTrack(call: PluginCall) { + val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") + val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) + if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "wifiTrackPermCallback") + return + } + doStartWifiTrack(call, bssid, intervalMs) + } + + @PermissionCallback + private fun wifiTrackPermCallback(call: PluginCall) { + if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { + val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") + val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) + doStartWifiTrack(call, bssid, intervalMs) + } else { + call.reject("Standortberechtigung für WLAN-Tracker abgelehnt") + } + } + + private fun doStartWifiTrack(call: PluginCall, bssid: String, intervalMs: Int) { + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") val connectedBssid = wifi.connectionInfo?.bssid + val mode = if (bssid.equals(connectedBssid, ignoreCase = true)) "connected" else "scan" + val runId = "wifi-${System.currentTimeMillis()}" + val run = WifiTrackRun(bssid, mode, intervalMs) + wifiTrackRuns[runId] = run + + if (mode == "connected") { + // Live-Polling vom verbundenen AP — kein Scan-Throttling + io.launch { + try { + while (run.active) { + @Suppress("DEPRECATION") val ci = wifi.connectionInfo + if (ci != null && ci.rssi != -127 && + bssid.equals(ci.bssid, ignoreCase = true)) { + val now = System.currentTimeMillis() + val ev = JSObject() + .put("runId", runId) + .put("ts", now) + .put("rssi", ci.rssi) + .put("source", "connected") + run.samples.add(ev) + notifyListeners("wifiSignal", ev) + } + Thread.sleep(intervalMs.toLong()) + } + } catch (_: Exception) { /* Tracker endet */ } + } + } else { + // Scan-Modus: BroadcastReceiver + periodisches startScan + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return + try { + val match = wifi.scanResults.firstOrNull { + it.BSSID.equals(bssid, ignoreCase = true) + } ?: return + val now = System.currentTimeMillis() + val ev = JSObject() + .put("runId", runId) + .put("ts", now) + .put("rssi", match.level) + .put("source", "scan") + run.samples.add(ev) + notifyListeners("wifiSignal", ev) + } catch (_: Exception) { /* ignorieren */ } + } + } + run.scanReceiver = receiver + context.applicationContext.registerReceiver( + receiver, + IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION), + ) + // Erster Scan sofort, dann alle 30 s neu triggern + io.launch { + try { + @Suppress("DEPRECATION") wifi.startScan() + while (run.active) { + Thread.sleep(30_000L) + if (!run.active) break + @Suppress("DEPRECATION") wifi.startScan() + } + } catch (_: Exception) { /* Tracker endet */ } + } + } + resolve(call, JSObject().put("runId", runId).put("mode", mode)) + } + + @PluginMethod + fun stopWifiTrack(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = wifiTrackRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") + run.active = false + run.scanReceiver?.let { + try { + context.applicationContext.unregisterReceiver(it) + } catch (_: Exception) { /* schon abgemeldet */ } + } + val samples = JSArray() + var min = Int.MAX_VALUE + var max = Int.MIN_VALUE + var sum = 0L + var n = 0 + run.samples.forEach { s -> + samples.put(JSObject() + .put("ts", s.optLong("ts")) + .put("rssi", s.optInt("rssi")) + .put("source", s.optString("source"))) + val r = s.optInt("rssi") + if (r < min) min = r + if (r > max) max = r + sum += r + n++ + } + val avg = if (n > 0) (sum / n).toInt() else 0 + resolve(call, JSObject() + .put("samples", samples) + .put("min", if (n > 0) min else 0) + .put("max", if (n > 0) max else 0) + .put("avg", avg)) + } + + /** Status eines Tracker-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ + @PluginMethod + fun getWifiTrackStatus(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = wifiTrackRuns[runId] + val samples = JSArray() + run?.samples?.forEach { s -> + samples.put(JSObject() + .put("ts", s.optLong("ts")) + .put("rssi", s.optInt("rssi")) + .put("source", s.optString("source"))) + } + resolve(call, JSObject() + .put("running", run != null && run.active) + .put("samples", samples) + .put("mode", run?.mode ?: "connected")) + } + /* --------------------------------------------------------------------- */ /* App-Update: APK herunterladen und Paketinstaller öffnen */ /* --------------------------------------------------------------------- */ diff --git a/native-plugin/NetDiagScannerPlugin.kt b/native-plugin/NetDiagScannerPlugin.kt index 6fb14dd..d9fa0c5 100644 --- a/native-plugin/NetDiagScannerPlugin.kt +++ b/native-plugin/NetDiagScannerPlugin.kt @@ -2,9 +2,14 @@ package de.data_it_solution.netdiag import android.Manifest import android.app.NotificationManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities import android.net.Uri import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo @@ -961,6 +966,339 @@ class NetDiagScannerPlugin : Plugin() { } catch (_: Exception) { } } + /* --------------------------------------------------------------------- */ + /* IP-Test — alle aktiven Netzwerk-Interfaces (WLAN, Ethernet, USB-RJ45) */ + /* --------------------------------------------------------------------- */ + + /** + * Listet alle aktiven Netzwerk-Interfaces des Geräts. Für jedes Interface + * werden IP, Präfix, Gateway, DNS, DHCP-Server (nur WLAN) und Link-Speed + * geliefert. Bei USB-RJ45-Adaptern wird die Speed aus + * /sys/class/net//speed gelesen — funktioniert ohne Root. + * + * Anwendungsfall: Adapter in eine Netzwerkdose stecken und sofort sehen, + * ob eine IP kommt und ob der Link 10/100/1000 Mbit ausspuckt. + */ + @PluginMethod + fun linkInfo(call: PluginCall) { + io.launch { + try { + val cm = context.applicationContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + val defaultIface = cm.activeNetwork?.let { cm.getLinkProperties(it)?.interfaceName } + + val arr = JSArray() + val networks: Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) cm.allNetworks + else emptyArray() + for (net in networks) { + val caps = cm.getNetworkCapabilities(net) ?: continue + val lp = cm.getLinkProperties(net) ?: continue + val iface = lp.interfaceName ?: continue + + val type = when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + // IPv4 + Präfix aus den LinkAddresses + var ipv4: String? = null + var prefix = 0 + for (la in lp.linkAddresses) { + val a = la.address + if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { + ipv4 = a.hostAddress + prefix = la.prefixLength + break + } + } + var gateway: String? = null + for (route in lp.routes) { + val gw = route.gateway + if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { + gateway = gw.hostAddress + break + } + } + val dnsArr = JSArray() + for (d in lp.dnsServers) { + if (d is Inet4Address) d.hostAddress?.let { dnsArr.put(it) } + } + + val obj = JSObject() + .put("iface", iface) + .put("type", type) + .put("isDefault", iface == defaultIface) + if (ipv4 != null) obj.put("ipv4", ipv4) + if (prefix > 0) obj.put("prefixLength", prefix) + if (gateway != null) obj.put("gateway", gateway) + obj.put("dns", dnsArr) + + // USB-RJ45-Heuristik + val ifLower = iface.lowercase() + val isUsb = ifLower.startsWith("rndis") || + ifLower.startsWith("usb") || + ifLower.startsWith("ecm") || + (type == "ethernet" && ifLower.matches(Regex("eth[1-9].*"))) + if (isUsb) obj.put("isUsbEthernet", true) + + // Link-Speed-Quellen + if (type == "wifi") { + try { + @Suppress("DEPRECATION") val ci = wifi.connectionInfo + if (ci != null) { + if (ci.linkSpeed > 0) obj.put("linkSpeedMbps", ci.linkSpeed) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + ci.rxLinkSpeedMbps > 0) { + obj.put("rxLinkSpeedMbps", ci.rxLinkSpeedMbps) + } + val ssid = ci.ssid?.trim('"') + if (!ssid.isNullOrEmpty() && ssid != "") { + obj.put("ssid", ssid) + } + if (!ci.bssid.isNullOrEmpty()) obj.put("bssid", ci.bssid) + if (ci.rssi != -127) obj.put("rssi", ci.rssi) + } + @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo + if (dhcp != null && dhcp.serverAddress != 0) { + obj.put("dhcpServer", intToIp(dhcp.serverAddress)) + if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration) + } + } catch (_: Exception) { /* WLAN-Info nicht greifbar */ } + } else if (type == "ethernet") { + val mbps = readEthernetSpeed(iface) + if (mbps != null) obj.put("linkSpeedMbps", mbps) + } + arr.put(obj) + } + resolve(call, JSObject().put("links", arr)) + } catch (e: Exception) { + call.reject("linkInfo: ${e.message}") + } + } + } + + /** + * Link-Speed eines Ethernet-Interfaces aus /sys/class/net//speed lesen. + * Liefert Mbit/s als Int (z.B. 1000 für Gigabit), oder null wenn nicht lesbar + * (auf manchen Android-12+-Geräten ist /sys/class/net/ selektiv gesperrt). + */ + private fun readEthernetSpeed(iface: String): Int? { + return try { + val f = File("/sys/class/net/$iface/speed") + if (!f.exists() || !f.canRead()) return null + val raw = f.readText().trim() + val n = raw.toIntOrNull() ?: return null + if (n <= 0) null else n + } catch (_: Exception) { + null + } + } + + /* --------------------------------------------------------------------- */ + /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ + /* --------------------------------------------------------------------- */ + + private val wifiTrackRuns = ConcurrentHashMap() + + private class WifiTrackRun( + val bssid: String, + val mode: String, // "connected" | "scan" + val intervalMs: Int, + ) { + @Volatile var active = true + val samples = java.util.concurrent.CopyOnWriteArrayList() + var scanReceiver: BroadcastReceiver? = null + } + + /** + * Aktiven WLAN-Scan triggern (System legt frische Ergebnisse in den Cache). + * Liefert `triggered=false` bei Android-Throttling (ab API 28: max. 4 + * Foreground-Scans / 2 Min). + */ + @PluginMethod + fun startWifiScan(call: PluginCall) { + if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "startWifiScanPermCallback") + return + } + doStartWifiScan(call) + } + + @PermissionCallback + private fun startWifiScanPermCallback(call: PluginCall) { + if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { + doStartWifiScan(call) + } else { + call.reject("Standortberechtigung für WLAN-Scan abgelehnt") + } + } + + private fun doStartWifiScan(call: PluginCall) { + try { + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") val triggered = wifi.startScan() + resolve(call, JSObject().put("triggered", triggered)) + } catch (e: Exception) { + call.reject("startWifiScan: ${e.message}") + } + } + + /** + * Empfangs-Tracker für ein BSSID starten. Wenn das BSSID das aktuell + * verbundene Netz ist: Live-RSSI aus `WifiManager.connectionInfo` alle + * `intervalMs` (kein Scan-Throttling). Sonst: periodischer `startScan` + * alle ~30 s, BroadcastReceiver lauscht auf `SCAN_RESULTS_AVAILABLE_ACTION` + * und liefert den Wert des passenden BSSIDs. + */ + @PluginMethod + fun startWifiTrack(call: PluginCall) { + val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") + val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) + if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { + requestPermissionForAlias("location", call, "wifiTrackPermCallback") + return + } + doStartWifiTrack(call, bssid, intervalMs) + } + + @PermissionCallback + private fun wifiTrackPermCallback(call: PluginCall) { + if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { + val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") + val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) + doStartWifiTrack(call, bssid, intervalMs) + } else { + call.reject("Standortberechtigung für WLAN-Tracker abgelehnt") + } + } + + private fun doStartWifiTrack(call: PluginCall, bssid: String, intervalMs: Int) { + val wifi = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") val connectedBssid = wifi.connectionInfo?.bssid + val mode = if (bssid.equals(connectedBssid, ignoreCase = true)) "connected" else "scan" + val runId = "wifi-${System.currentTimeMillis()}" + val run = WifiTrackRun(bssid, mode, intervalMs) + wifiTrackRuns[runId] = run + + if (mode == "connected") { + // Live-Polling vom verbundenen AP — kein Scan-Throttling + io.launch { + try { + while (run.active) { + @Suppress("DEPRECATION") val ci = wifi.connectionInfo + if (ci != null && ci.rssi != -127 && + bssid.equals(ci.bssid, ignoreCase = true)) { + val now = System.currentTimeMillis() + val ev = JSObject() + .put("runId", runId) + .put("ts", now) + .put("rssi", ci.rssi) + .put("source", "connected") + run.samples.add(ev) + notifyListeners("wifiSignal", ev) + } + Thread.sleep(intervalMs.toLong()) + } + } catch (_: Exception) { /* Tracker endet */ } + } + } else { + // Scan-Modus: BroadcastReceiver + periodisches startScan + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return + try { + val match = wifi.scanResults.firstOrNull { + it.BSSID.equals(bssid, ignoreCase = true) + } ?: return + val now = System.currentTimeMillis() + val ev = JSObject() + .put("runId", runId) + .put("ts", now) + .put("rssi", match.level) + .put("source", "scan") + run.samples.add(ev) + notifyListeners("wifiSignal", ev) + } catch (_: Exception) { /* ignorieren */ } + } + } + run.scanReceiver = receiver + context.applicationContext.registerReceiver( + receiver, + IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION), + ) + // Erster Scan sofort, dann alle 30 s neu triggern + io.launch { + try { + @Suppress("DEPRECATION") wifi.startScan() + while (run.active) { + Thread.sleep(30_000L) + if (!run.active) break + @Suppress("DEPRECATION") wifi.startScan() + } + } catch (_: Exception) { /* Tracker endet */ } + } + } + resolve(call, JSObject().put("runId", runId).put("mode", mode)) + } + + @PluginMethod + fun stopWifiTrack(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = wifiTrackRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") + run.active = false + run.scanReceiver?.let { + try { + context.applicationContext.unregisterReceiver(it) + } catch (_: Exception) { /* schon abgemeldet */ } + } + val samples = JSArray() + var min = Int.MAX_VALUE + var max = Int.MIN_VALUE + var sum = 0L + var n = 0 + run.samples.forEach { s -> + samples.put(JSObject() + .put("ts", s.optLong("ts")) + .put("rssi", s.optInt("rssi")) + .put("source", s.optString("source"))) + val r = s.optInt("rssi") + if (r < min) min = r + if (r > max) max = r + sum += r + n++ + } + val avg = if (n > 0) (sum / n).toInt() else 0 + resolve(call, JSObject() + .put("samples", samples) + .put("min", if (n > 0) min else 0) + .put("max", if (n > 0) max else 0) + .put("avg", avg)) + } + + /** Status eines Tracker-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ + @PluginMethod + fun getWifiTrackStatus(call: PluginCall) { + val runId = call.getString("runId") ?: return call.reject("runId fehlt") + val run = wifiTrackRuns[runId] + val samples = JSArray() + run?.samples?.forEach { s -> + samples.put(JSObject() + .put("ts", s.optLong("ts")) + .put("rssi", s.optInt("rssi")) + .put("source", s.optString("source"))) + } + resolve(call, JSObject() + .put("running", run != null && run.active) + .put("samples", samples) + .put("mode", run?.mode ?: "connected")) + } + /* --------------------------------------------------------------------- */ /* App-Update: APK herunterladen und Paketinstaller öffnen */ /* --------------------------------------------------------------------- */ diff --git a/src/lib/db.ts b/src/lib/db.ts index fc99347..5fd0c8b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -28,6 +28,7 @@ function normalizeProtocol(p: Protocol): Protocol { p.measurements ??= []; p.savedScans ??= []; p.monitorSessions ??= []; + p.wifiTrackSessions ??= []; return p; } diff --git a/src/lib/protocols.ts b/src/lib/protocols.ts index 3e890fb..e86d86e 100644 --- a/src/lib/protocols.ts +++ b/src/lib/protocols.ts @@ -3,7 +3,13 @@ */ import { saveProtocol } from './db'; -import type { Device, Measurement, Protocol, SavedScan } from './types'; +import type { + Device, + Measurement, + Protocol, + SavedScan, + WifiTrackSession, +} from './types'; /** Eindeutige ID erzeugen */ export function uid(): string { @@ -106,3 +112,12 @@ export function addMeasurement( protocol.measurements.push(created); return created; } + +/** WLAN-Empfangstracker-Sitzung an das Protokoll anhängen */ +export function addWifiTrackSession( + protocol: Protocol, + s: WifiTrackSession, +): WifiTrackSession { + (protocol.wifiTrackSessions ??= []).push(s); + return s; +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 9b3d87f..1dd3dab 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -7,6 +7,7 @@ */ import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; +import type { LinkInfo, WifiSignalSample } from './types'; /* --- Datentypen der Plugin-Antworten --- */ @@ -50,6 +51,13 @@ export interface MonitorEventData { /** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */ durationSec?: number; } +/** Ein RSSI-Sample des WLAN-Empfangstrackers (vom Plugin) */ +export interface WifiSignalEvent { + runId: string; + ts: number; + rssi: number; + source: 'connected' | 'scan'; +} export interface OpenPort { port: number; service?: string; @@ -140,6 +148,28 @@ export interface NetDiagScannerPlugin { getMonitorStatus(opts: { runId: string; }): Promise<{ running: boolean; events: MonitorEventData[] }>; + /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ + linkInfo(): Promise<{ links: LinkInfo[] }>; + /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ + startWifiScan(): Promise<{ triggered: boolean }>; + /** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */ + startWifiTrack(opts: { bssid: string; intervalMs: number }): Promise<{ + runId: string; + mode: 'connected' | 'scan'; + }>; + /** WLAN-Empfangstracker beenden — liefert alle Samples + Statistik */ + stopWifiTrack(opts: { runId: string }): Promise<{ + samples: WifiSignalSample[]; + min: number; + max: number; + avg: number; + }>; + /** Status eines WLAN-Tracker-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ + getWifiTrackStatus(opts: { runId: string }): Promise<{ + running: boolean; + samples: WifiSignalSample[]; + mode: 'connected' | 'scan'; + }>; } const native = registerPlugin('NetDiagScanner'); @@ -157,6 +187,14 @@ const monitorListeners = new Set<(e: MonitorEventData) => void>(); let mockMonitorTimer: ReturnType | undefined; let mockMonitorEvents: MonitorEventData[] = []; +/* --- WLAN-Empfangstracker: Ereignis-Verteilung + Mock-Simulation --- */ +const wifiSignalListeners = new Set<(e: WifiSignalEvent) => void>(); +let mockWifiTrackTimer: ReturnType | undefined; +let mockWifiTrackSamples: WifiSignalSample[] = []; +let mockWifiTrackMode: 'connected' | 'scan' = 'connected'; +/** simulierter RSSI-Random-Walk fürs Browser-Mock */ +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' }; @@ -314,6 +352,86 @@ const mock: NetDiagScannerPlugin = { async getMonitorStatus() { return { running: mockMonitorTimer !== undefined, events: mockMonitorEvents }; }, + async linkInfo() { + // Mock: ein verbundenes WLAN + ein simulierter USB-RJ45-Adapter mit 1 Gbit + return { + links: [ + { + iface: 'eth0', + type: 'ethernet', + isUsbEthernet: true, + isDefault: true, + ipv4: '192.168.1.42', + prefixLength: 24, + gateway: '192.168.1.1', + dns: ['192.168.1.1', '1.1.1.1'], + linkSpeedMbps: 1000, + }, + { + iface: 'wlan0', + type: 'wifi', + isDefault: false, + ipv4: '192.168.10.5', + prefixLength: 24, + gateway: '192.168.10.1', + dns: ['192.168.10.1'], + dhcpServer: '192.168.10.1', + leaseSec: 86400, + linkSpeedMbps: 866, + rxLinkSpeedMbps: 866, + ssid: 'AllesWattLaeuft', + bssid: 'AA:BB:CC:11:22:33', + rssi: -52, + }, + ], + }; + }, + async startWifiScan() { + return { triggered: true }; + }, + async startWifiTrack(opts) { + const runId = 'mock-wifi-' + Date.now(); + mockWifiTrackSamples = []; + // Mock-Annahme: das verbundene WLAN ist 'AA:BB:CC:11:22:33' (siehe linkInfo) + mockWifiTrackMode = opts.bssid === 'AA:BB:CC:11:22:33' ? 'connected' : 'scan'; + mockWifiTrackRssi = -55; + const tick = () => { + // Random-Walk -50…-80 dBm + mockWifiTrackRssi += rnd(-3, 3); + mockWifiTrackRssi = Math.max(-85, Math.min(-40, mockWifiTrackRssi)); + const ev: WifiSignalEvent = { + runId, + ts: Date.now(), + rssi: Math.round(mockWifiTrackRssi), + source: mockWifiTrackMode, + }; + mockWifiTrackSamples.push({ ts: ev.ts, rssi: ev.rssi, source: ev.source }); + wifiSignalListeners.forEach((cb) => cb(ev)); + }; + // Im connected-Mock alle 500ms, im scan-Mock alle 4s (im echten Plugin 30s, hier + // schneller damit die UI im Browser sichtbar etwas tut) + const intervalMs = mockWifiTrackMode === 'connected' ? opts.intervalMs : 4000; + mockWifiTrackTimer = setInterval(tick, intervalMs); + tick(); + return { runId, mode: mockWifiTrackMode }; + }, + async stopWifiTrack() { + if (mockWifiTrackTimer) clearInterval(mockWifiTrackTimer); + mockWifiTrackTimer = undefined; + const samples = mockWifiTrackSamples; + const vals = samples.map((s) => s.rssi); + const min = vals.length ? Math.min(...vals) : 0; + const max = vals.length ? Math.max(...vals) : 0; + const avg = vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : 0; + return { samples, min, max, avg }; + }, + async getWifiTrackStatus() { + return { + running: mockWifiTrackTimer !== undefined, + samples: mockWifiTrackSamples, + mode: mockWifiTrackMode, + }; + }, }; /** Aktive Scanner-Implementierung: nativ auf dem Gerät, Mock im Browser */ @@ -342,3 +460,26 @@ export function onMonitorEvent(cb: (e: MonitorEventData) => void): () => void { monitorListeners.delete(cb); }; } + +/** + * Auf WLAN-Signal-Samples des Empfangstrackers hören. Gibt die Abmeldefunktion zurück. + */ +export function onWifiSignal(cb: (e: WifiSignalEvent) => void): () => void { + if (Capacitor.isNativePlatform()) { + const handle = ( + native as unknown as { + addListener( + name: string, + cb: (e: WifiSignalEvent) => void, + ): Promise; + } + ).addListener('wifiSignal', cb); + return () => { + void handle.then((h) => h.remove()); + }; + } + wifiSignalListeners.add(cb); + return () => { + wifiSignalListeners.delete(cb); + }; +} diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 1f9c903..591298d 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -9,7 +9,6 @@ import type { Tool, ToolCategory } from './types'; -import { dhcpCheckTool } from './netzwerk/dhcpcheck'; import { ipConflictTool } from './netzwerk/ipconflict'; import { ipScanTool } from './netzwerk/ipscan'; import { pingTool } from './netzwerk/ping'; @@ -17,18 +16,22 @@ import { portScanTool } from './netzwerk/portscan'; import { snmpTool } from './netzwerk/snmp'; import { stressTestTool } from './netzwerk/stresstest'; import { tracerouteTool } from './netzwerk/traceroute'; -import { wifiScanTool } from './netzwerk/wifiscan'; import { iperfTool } from './internet/iperf'; -/** Alle registrierten Tools */ +/** + * Alle registrierten Tools. + * + * IP-Test und WLAN-Empfangstracker sind KEINE klassischen Tools, weil sie + * Live-Aktualisierung und Mehrfachansichten brauchen — sie haben eigene + * Routen (`/protokoll/{id}/iptest/` und `/protokoll/{id}/wifi/`) und werden + * im Werkzeug-Grid als Link-Kacheln eingeblendet. + */ export const TOOLS: Tool[] = [ // Netzwerk ipScanTool, portScanTool, pingTool, - wifiScanTool, - dhcpCheckTool, ipConflictTool, snmpTool, tracerouteTool, diff --git a/src/lib/tools/netzwerk/dhcpcheck.ts b/src/lib/tools/netzwerk/dhcpcheck.ts deleted file mode 100644 index c50abe7..0000000 --- a/src/lib/tools/netzwerk/dhcpcheck.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Tool: DHCP-Info — zeigt den DHCP-Server, von dem das Gerät seine Adresse hat. - * - * Eine aktive Rogue-DHCP-Suche ist auf nicht gerootetem Android nicht möglich: - * der DHCP-Server antwortet auf den privilegierten UDP-Port 68, den eine - * normale App nicht binden kann. Darum hier nur die verlässliche Lease-Info, - * die das Betriebssystem selbst bezogen hat. - */ - -import { scanner } from '../../scanner'; -import type { MeasureStatus, Tool } from '../types'; - -export const dhcpCheckTool: Tool = { - id: 'dhcpcheck', - category: 'netzwerk', - name: 'DHCP-Info', - icon: 'server', - description: 'Zeigt DHCP-Server, Lease-Dauer und DNS des Geräts.', - scope: 'protocol', - params: [], - async run() { - const info = await scanner.dhcpInfo(); - const hasServer = info.server !== ''; - const status: MeasureStatus = hasServer ? 0 : 1; - return { - label: hasServer ? `DHCP-Server: ${info.server}` : 'Kein DHCP-Server ermittelbar', - result: { - server: info.server || '—', - lease: info.lease ? `${info.lease} s` : '—', - gateway: info.gateway || '—', - dns: info.dns.length ? info.dns : ['—'], - hinweis: hasServer - ? 'Nur der DHCP-Server des Geräts — Rogue-DHCP-Erkennung braucht Root.' - : 'Auf Ethernet / ohne WLAN nicht ermittelbar.', - }, - measureStatus: status, - }; - }, -}; diff --git a/src/lib/tools/netzwerk/wifiscan.ts b/src/lib/tools/netzwerk/wifiscan.ts deleted file mode 100644 index c5a857f..0000000 --- a/src/lib/tools/netzwerk/wifiscan.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Tool: WLAN-Scan — umliegende Netze, Kanäle und Signalstärke. - */ - -import { scanner } from '../../scanner'; -import type { Tool } from '../types'; - -export const wifiScanTool: Tool = { - id: 'wifiscan', - category: 'netzwerk', - name: 'WLAN-Scan', - icon: 'wifi', - description: 'Listet WLAN-Netze, Kanäle und Signalstärke.', - scope: 'protocol', - params: [], - async run() { - const { networks } = await scanner.wifiScan(); - const sorted = [...networks].sort((a, b) => b.rssi - a.rssi); - return { - label: `${networks.length} WLAN-Netze gefunden`, - result: { - count: networks.length, - netze: sorted.map((n) => `${n.ssid} (Kanal ${n.channel}, ${n.rssi} dBm, ${n.band})`), - }, - measureStatus: 0, - }; - }, -}; diff --git a/src/lib/types.ts b/src/lib/types.ts index 1c9b408..ecb1eb8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -106,6 +106,62 @@ export interface DeviceMonitorSession { runId?: string; } +/** Aktives Netzwerk-Interface des Geräts (WLAN/Ethernet/Mobilfunk) */ +export interface LinkInfo { + /** Name des Interfaces, z.B. "wlan0", "eth0", "rndis0" */ + iface: string; + type: 'wifi' | 'ethernet' | 'cellular' | 'other'; + /** USB-RJ45-Adapter erkannt (heuristisch über iface-Name) */ + isUsbEthernet?: boolean; + /** ist dies das primäre Interface (Standardroute)? */ + isDefault?: boolean; + ipv4?: string; + prefixLength?: number; + gateway?: string; + dns?: string[]; + /** DHCP-Server (nur für WLAN ermittelbar) */ + dhcpServer?: string; + leaseSec?: number; + /** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net//speed */ + linkSpeedMbps?: number; + /** Empfangs-Linkspeed (nur WLAN, API 29+) */ + rxLinkSpeedMbps?: number; + /** WLAN-SSID (nur wenn type='wifi') */ + ssid?: string; + /** WLAN-BSSID (nur wenn type='wifi') */ + bssid?: string; + /** Empfangsstärke in dBm (nur wenn type='wifi') */ + rssi?: number; +} + +/** Einzelne RSSI-Probe des WLAN-Empfangstrackers */ +export interface WifiSignalSample { + ts: number; + rssi: number; + /** woher der Wert kam: 'connected' = Live-RSSI vom verbundenen AP, 'scan' = aus Scan-Ergebnis */ + source: 'connected' | 'scan'; +} + +/** Tracker-Session für die WLAN-Empfangsprüfung beim Durchlaufen */ +export interface WifiTrackSession { + id: string; + name: string; + startedAt: number; + endedAt?: number; + bssid: string; + ssid: string; + band: string; + channel: number; + samples: WifiSignalSample[]; + min?: number; + max?: number; + avg?: number; + status: 'running' | 'stopped'; + runId?: string; + /** Mess-Modus: live (verbundenes Netz) oder periodischer Scan */ + mode?: 'connected' | 'scan'; +} + /** Ampel-Bewertung einer Messung */ export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail @@ -146,6 +202,8 @@ export interface Protocol { savedScans?: SavedScan[]; /** Geräte-Überwachungs-Sitzungen (nur lokal, wird nicht synchronisiert) */ monitorSessions?: DeviceMonitorSession[]; + /** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */ + wifiTrackSessions?: WifiTrackSession[]; /** true solange noch nicht zum Server synchronisiert */ dirty: boolean; updatedAt: number; diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index 1094234..eea0e88 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -278,6 +278,28 @@ Erreichbarkeit mehrerer Geräte dauerhaft überwachen. + + + + IP-Test + + Dose prüfen: IP, DHCP und 10/100/1000 Mbit ablesen. + + + + + + WLAN-Empfang + + Empfangsstärke beim Durchgehen aufzeichnen. + + diff --git a/src/routes/protokoll/[id]/iptest/+page.svelte b/src/routes/protokoll/[id]/iptest/+page.svelte new file mode 100644 index 0000000..392d454 --- /dev/null +++ b/src/routes/protokoll/[id]/iptest/+page.svelte @@ -0,0 +1,266 @@ + + +{#if protocol} + + +
+ {#if loading} +

Verbindungen werden ermittelt …

+ {:else if !primary} + +
+

Keine aktive Verbindung erkannt

+

+ USB-OTG/RJ45-Adapter in die Dose stecken und 2 s warten — sobald eine IP + kommt, erscheint sie hier. Bei WLAN: Verbindung in den Android-Einstellungen + aufbauen. +

+
+ {:else} + +
+
+ {#if primary.type === 'ethernet'} + + {:else if primary.type === 'wifi'} + + {:else} + + {/if} + {typeLabel(primary)} +
+ +
+
+
IP-Adresse
+
+ {primary.ipv4 ?? '—'}{primary.prefixLength ? ' / ' + primary.prefixLength : ''} +
+
+
+
Link
+
+ {speedLabel(primary.linkSpeedMbps)} +
+
+
+
Gateway
+
{primary.gateway ?? '—'}
+
+
+
DHCP-Server
+
{primary.dhcpServer ?? '—'}
+
+
+
DNS
+
+ {primary.dns?.length ? primary.dns.join(', ') : '—'} +
+
+ {#if primary.type === 'wifi'} +
+
Signal
+
+ {primary.rssi != null ? primary.rssi + ' dBm' : '—'} +
+
+
+
BSSID
+
{primary.bssid ?? '—'}
+
+ {/if} +
+ +
+ + Auto: alle 2 s + + +
+
+ + + {#if others.length > 0} +

Weitere Verbindungen

+ {#each others as l (l.iface)} +
+
+ {#if l.type === 'ethernet'} + + {:else if l.type === 'wifi'} + + {:else} + + {/if} + {typeLabel(l)} + · + {l.ipv4 ?? 'keine IP'} + + {speedLabel(l.linkSpeedMbps)} + {#if l.type === 'wifi' && l.rssi != null} + {l.rssi} dBm + {/if} +
+
+ {/each} + {/if} + {/if} +
+{:else} +
Lädt …
+{/if} + +{#if saveOpen} + (saveOpen = false)} + /> +{/if} diff --git a/src/routes/protokoll/[id]/wifi/+page.svelte b/src/routes/protokoll/[id]/wifi/+page.svelte new file mode 100644 index 0000000..838a9d3 --- /dev/null +++ b/src/routes/protokoll/[id]/wifi/+page.svelte @@ -0,0 +1,412 @@ + + +{#if protocol} + + +
+ {#if running && session} + +
+
+ + {session.mode === 'connected' + ? 'Live (verbundenes Netz, 0,5 s)' + : 'Scan-Modus (~30 s · Android-Limit)'} + + Kanal {session.channel} · {session.band} +
+ +
+ + {currentRssi ?? '—'} + + dBm + {rssiLabel(currentRssi)} +
+ + + {#if sparklinePoints} + + + + {/if} + +
+
+
Min
+
{session.min ?? '—'} dBm
+
+
+
Ø
+
{session.avg ?? '—'} dBm
+
+
+
Max
+
{session.max ?? '—'} dBm
+
+
+ +

+ {session.samples.length} Samples · seit {fmtTime(session.startedAt)} +

+ + +
+ {:else} + +
+

WLAN auswählen

+ +
+ + {#if throttled} +

+ Android-Scan-Throttling — letztes Cache-Ergebnis wird gezeigt. In 2 Min nochmal. +

+ {/if} + + {#if networks.length === 0} +

Noch keine Netze — bitte Scan starten.

+ {:else} +
+ {#each networks as n (n.bssid)} + {@const isConnected = n.bssid === connectedBssid} + + {/each} +
+

+ Tippe ein Netz an — der Tracker zeigt das Signal live, während du durchs Gebäude + gehst. Verbundenes Netz: alle 0,5 s. Fremdes Netz: alle ~30 s (Android-Limit). +

+ {/if} + {/if} + + + {#if !running && pastSessions.length > 0} +

Frühere Aufzeichnungen

+ {#each pastSessions as s (s.id)} +
+ + {#if expanded === s.id} +
+
+
+
Min
+
{s.min ?? '—'} dBm
+
+
+
Ø
+
{s.avg ?? '—'} dBm
+
+
+
Max
+
{s.max ?? '—'} dBm
+
+
+

+ BSSID {s.bssid} · Kanal {s.channel} · {s.band} +

+
+ {/if} +
+ {/each} + {/if} +
+{:else} +
Lädt …
+{/if}