Phase 6: IP-Test (Dose prüfen) und WLAN-Empfangstracker [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m49s

IP-Test: USB-RJ45-Adapter in Netzwerkdose stecken und sofort IP-Adresse,
DHCP-Server, Gateway und Link-Geschwindigkeit (10/100/1000 Mbit) ablesen.
Auto-Refresh alle 2 s, Speichern mit optionalem Raum/Dose-Name ins Protokoll.

WLAN-Empfangstracker: Netz auswählen und beim Durchgehen live RSSI verfolgen.
Hybrid-Modus: 500 ms Polling bei verbundenem Netz (kein Scan-Throttling),
~30 s Scan-Sweep bei Fremd-BSSID. Sessions mit Samples, Min/Max/Avg und
Sparkline-Verlauf werden im Protokoll gespeichert.

Ersetzt DHCP-Info-Tool und WLAN-Scan-Tool (eigene Routen /iptest/ + /wifi/).
Kotlin-Plugin: linkInfo(), startWifiScan(), startWifiTrack/stop/status().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-20 10:03:25 +02:00
parent d2df3ee929
commit 3c95ff6b07
12 changed files with 1600 additions and 73 deletions

View file

@ -2,9 +2,14 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.app.NotificationManager import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
@ -961,6 +966,339 @@ class NetDiagScannerPlugin : Plugin() {
} catch (_: Exception) { } } 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/<iface>/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<Network> =
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 != "<unknown 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/<iface>/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<String, WifiTrackRun>()
private class WifiTrackRun(
val bssid: String,
val mode: String, // "connected" | "scan"
val intervalMs: Int,
) {
@Volatile var active = true
val samples = java.util.concurrent.CopyOnWriteArrayList<JSObject>()
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 */ /* App-Update: APK herunterladen und Paketinstaller öffnen */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -2,9 +2,14 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.app.NotificationManager import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
@ -961,6 +966,339 @@ class NetDiagScannerPlugin : Plugin() {
} catch (_: Exception) { } } 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/<iface>/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<Network> =
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 != "<unknown 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/<iface>/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<String, WifiTrackRun>()
private class WifiTrackRun(
val bssid: String,
val mode: String, // "connected" | "scan"
val intervalMs: Int,
) {
@Volatile var active = true
val samples = java.util.concurrent.CopyOnWriteArrayList<JSObject>()
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 */ /* App-Update: APK herunterladen und Paketinstaller öffnen */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -28,6 +28,7 @@ function normalizeProtocol(p: Protocol): Protocol {
p.measurements ??= []; p.measurements ??= [];
p.savedScans ??= []; p.savedScans ??= [];
p.monitorSessions ??= []; p.monitorSessions ??= [];
p.wifiTrackSessions ??= [];
return p; return p;
} }

View file

@ -3,7 +3,13 @@
*/ */
import { saveProtocol } from './db'; import { saveProtocol } from './db';
import type { Device, Measurement, Protocol, SavedScan } from './types'; import type {
Device,
Measurement,
Protocol,
SavedScan,
WifiTrackSession,
} from './types';
/** Eindeutige ID erzeugen */ /** Eindeutige ID erzeugen */
export function uid(): string { export function uid(): string {
@ -106,3 +112,12 @@ export function addMeasurement(
protocol.measurements.push(created); protocol.measurements.push(created);
return 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;
}

View file

@ -7,6 +7,7 @@
*/ */
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import type { LinkInfo, WifiSignalSample } from './types';
/* --- Datentypen der Plugin-Antworten --- */ /* --- Datentypen der Plugin-Antworten --- */
@ -50,6 +51,13 @@ export interface MonitorEventData {
/** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */ /** Dauer des vorangegangenen Ausfalls in Sekunden (nur bei 'up') */
durationSec?: number; 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 { export interface OpenPort {
port: number; port: number;
service?: string; service?: string;
@ -140,6 +148,28 @@ export interface NetDiagScannerPlugin {
getMonitorStatus(opts: { getMonitorStatus(opts: {
runId: string; runId: string;
}): Promise<{ running: boolean; events: MonitorEventData[] }>; }): 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<NetDiagScannerPlugin>('NetDiagScanner'); const native = registerPlugin<NetDiagScannerPlugin>('NetDiagScanner');
@ -157,6 +187,14 @@ const monitorListeners = new Set<(e: MonitorEventData) => void>();
let mockMonitorTimer: ReturnType<typeof setInterval> | undefined; let mockMonitorTimer: ReturnType<typeof setInterval> | undefined;
let mockMonitorEvents: MonitorEventData[] = []; let mockMonitorEvents: MonitorEventData[] = [];
/* --- WLAN-Empfangstracker: Ereignis-Verteilung + Mock-Simulation --- */
const wifiSignalListeners = new Set<(e: WifiSignalEvent) => void>();
let mockWifiTrackTimer: ReturnType<typeof setInterval> | undefined;
let mockWifiTrackSamples: WifiSignalSample[] = [];
let mockWifiTrackMode: 'connected' | 'scan' = 'connected';
/** simulierter RSSI-Random-Walk fürs Browser-Mock */
let mockWifiTrackRssi = -55;
const mock: NetDiagScannerPlugin = { const mock: NetDiagScannerPlugin = {
async getLocalSubnet() { 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' };
@ -314,6 +352,86 @@ const mock: NetDiagScannerPlugin = {
async getMonitorStatus() { async getMonitorStatus() {
return { running: mockMonitorTimer !== undefined, events: mockMonitorEvents }; 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 */ /** 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); 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<PluginListenerHandle>;
}
).addListener('wifiSignal', cb);
return () => {
void handle.then((h) => h.remove());
};
}
wifiSignalListeners.add(cb);
return () => {
wifiSignalListeners.delete(cb);
};
}

View file

@ -9,7 +9,6 @@
import type { Tool, ToolCategory } from './types'; import type { Tool, ToolCategory } from './types';
import { dhcpCheckTool } from './netzwerk/dhcpcheck';
import { ipConflictTool } from './netzwerk/ipconflict'; import { ipConflictTool } from './netzwerk/ipconflict';
import { ipScanTool } from './netzwerk/ipscan'; import { ipScanTool } from './netzwerk/ipscan';
import { pingTool } from './netzwerk/ping'; import { pingTool } from './netzwerk/ping';
@ -17,18 +16,22 @@ import { portScanTool } from './netzwerk/portscan';
import { snmpTool } from './netzwerk/snmp'; import { snmpTool } from './netzwerk/snmp';
import { stressTestTool } from './netzwerk/stresstest'; import { stressTestTool } from './netzwerk/stresstest';
import { tracerouteTool } from './netzwerk/traceroute'; import { tracerouteTool } from './netzwerk/traceroute';
import { wifiScanTool } from './netzwerk/wifiscan';
import { iperfTool } from './internet/iperf'; 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[] = [ export const TOOLS: Tool[] = [
// Netzwerk // Netzwerk
ipScanTool, ipScanTool,
portScanTool, portScanTool,
pingTool, pingTool,
wifiScanTool,
dhcpCheckTool,
ipConflictTool, ipConflictTool,
snmpTool, snmpTool,
tracerouteTool, tracerouteTool,

View file

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

View file

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

View file

@ -106,6 +106,62 @@ export interface DeviceMonitorSession {
runId?: string; 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/<iface>/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 */ /** Ampel-Bewertung einer Messung */
export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail
@ -146,6 +202,8 @@ export interface Protocol {
savedScans?: SavedScan[]; savedScans?: SavedScan[];
/** Geräte-Überwachungs-Sitzungen (nur lokal, wird nicht synchronisiert) */ /** Geräte-Überwachungs-Sitzungen (nur lokal, wird nicht synchronisiert) */
monitorSessions?: DeviceMonitorSession[]; monitorSessions?: DeviceMonitorSession[];
/** WLAN-Empfangstracker-Sessions (nur lokal, wird nicht synchronisiert) */
wifiTrackSessions?: WifiTrackSession[];
/** true solange noch nicht zum Server synchronisiert */ /** true solange noch nicht zum Server synchronisiert */
dirty: boolean; dirty: boolean;
updatedAt: number; updatedAt: number;

View file

@ -278,6 +278,28 @@
Erreichbarkeit mehrerer Geräte dauerhaft überwachen. Erreichbarkeit mehrerer Geräte dauerhaft überwachen.
</span> </span>
</a> </a>
<!-- IP-Test: USB-RJ45 in Dose stecken, IP + Link-Speed live anzeigen -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/iptest/"
>
<Icons.Cable size={20} class="text-sky-400" />
<span class="text-sm font-medium">IP-Test</span>
<span class="text-[11px] leading-tight text-zinc-500">
Dose prüfen: IP, DHCP und 10/100/1000 Mbit ablesen.
</span>
</a>
<!-- WLAN-Empfangstracker: Netz anklicken, durchs Gebäude laufen -->
<a
class="flex flex-col items-start gap-1 rounded-lg bg-zinc-800 p-3 active:bg-zinc-700"
href="/protokoll/{protocol.clientUuid}/wifi/"
>
<Icons.Wifi size={20} class="text-sky-400" />
<span class="text-sm font-medium">WLAN-Empfang</span>
<span class="text-[11px] leading-tight text-zinc-500">
Empfangsstärke beim Durchgehen aufzeichnen.
</span>
</a>
</div> </div>
</section> </section>

View file

@ -0,0 +1,266 @@
<script lang="ts">
/**
* IP-Test — Anwendungsfall „Dose prüfen":
* USB-RJ45-Adapter in eine Netzwerkdose stecken, App zeigt sofort, ob eine
* IP-Adresse vom DHCP-Server kommt und wie schnell der Link ist (10/100/1000).
*
* Die Seite refresht alle 2 s automatisch. Per „Speichern…" wird der Stand
* des primären Interfaces als Messung mit optionalem Raum-/Dose-Namen ins
* Protokoll abgelegt.
*/
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Cable, Wifi, Signal, RefreshCw } from 'lucide-svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
import { getProtocol, saveProtocol } from '$lib/db';
import { addMeasurement } from '$lib/protocols';
import { scanner } from '$lib/scanner';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { LinkInfo, Protocol } from '$lib/types';
let protocol = $state<Protocol | null>(null);
let links = $state<LinkInfo[]>([]);
let loading = $state(true);
let saveOpen = $state(false);
let refreshTimer: ReturnType<typeof setInterval> | undefined;
/** Primäres Interface: erst Default-Route, sonst höchste Link-Speed, sonst erstes */
const primary = $derived(
links.find((l) => l.isDefault) ??
links.slice().sort((a, b) => (b.linkSpeedMbps ?? 0) - (a.linkSpeedMbps ?? 0))[0],
);
const others = $derived(links.filter((l) => l !== primary));
onMount(async () => {
const p = await getProtocol($page.params.id ?? '');
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
await refresh();
// Alle 2 s aktualisieren — wenn der User den Adapter zwischendrin einsteckt,
// taucht das Ethernet-Interface ohne weiteren Klick auf
refreshTimer = setInterval(refresh, 2000);
});
onDestroy(() => {
if (refreshTimer) clearInterval(refreshTimer);
});
async function refresh() {
try {
const r = await scanner.linkInfo();
links = r.links;
loading = false;
} catch (e) {
// Plugin-Methode evtl. noch nicht im APK gebaut — leise ignorieren
loading = false;
console.warn('linkInfo fehlgeschlagen:', e);
}
}
function typeLabel(l: LinkInfo): string {
if (l.type === 'ethernet') return l.isUsbEthernet ? 'Ethernet (USB-RJ45)' : 'Ethernet';
if (l.type === 'wifi') return 'WLAN' + (l.ssid ? ' · ' + l.ssid : '');
if (l.type === 'cellular') return 'Mobilfunk';
return l.iface;
}
function speedLabel(mbps?: number): string {
if (mbps == null) return '—';
if (mbps >= 1000) return Math.round(mbps / 1000) + ' Gbit/s';
return mbps + ' Mbit/s';
}
function speedColor(mbps?: number): string {
if (mbps == null) return 'text-zinc-500';
if (mbps >= 1000) return 'text-emerald-400';
if (mbps >= 100) return 'text-amber-400';
return 'text-red-400';
}
function rssiColor(rssi?: number): string {
if (rssi == null) return 'text-zinc-500';
if (rssi >= -55) return 'text-emerald-400';
if (rssi >= -70) return 'text-amber-400';
return 'text-red-400';
}
async function persist(): Promise<void> {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
function openSave() {
if (!primary) {
toast.show('Keine Verbindung — nichts zu speichern', 'info');
return;
}
saveOpen = true;
}
async function doSave(name: string) {
saveOpen = false;
if (!protocol || !primary) return;
const raum = name.trim();
addMeasurement(protocol, {
tool: 'iptest',
category: 'netzwerk',
label:
(raum ? raum + ' · ' : '') +
typeLabel(primary) +
' · ' +
(primary.ipv4 ?? 'keine IP') +
' · ' +
speedLabel(primary.linkSpeedMbps),
params: {},
result: {
raum: raum || undefined,
...primary,
},
measureStatus: primary.ipv4 ? 0 : 2,
dateMeasure: Date.now(),
});
await persist();
toast.show('Messung gespeichert', 'success');
}
</script>
{#if protocol}
<AppHeader title="IP-Test" subtitle={protocol.label} back />
<div class="flex-1 overflow-y-auto p-3">
{#if loading}
<p class="text-sm text-zinc-500">Verbindungen werden ermittelt …</p>
{:else if !primary}
<!-- Kein Interface erkannt -->
<div class="rounded-lg bg-zinc-900 p-4">
<p class="text-sm font-medium text-amber-400">Keine aktive Verbindung erkannt</p>
<p class="mt-1 text-xs leading-tight text-zinc-500">
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.
</p>
</div>
{:else}
<!-- Primäres Interface (großes Status-Banner) -->
<div class="rounded-lg bg-zinc-900 p-3">
<div class="flex items-center gap-2">
{#if primary.type === 'ethernet'}
<Cable size={20} class="text-emerald-400" />
{:else if primary.type === 'wifi'}
<Wifi size={20} class="text-sky-400" />
{:else}
<Signal size={20} class="text-zinc-400" />
{/if}
<span class="text-sm font-medium">{typeLabel(primary)}</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-2 text-xs">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">IP-Adresse</div>
<div class="text-base font-semibold text-zinc-100">
{primary.ipv4 ?? '—'}{primary.prefixLength ? ' / ' + primary.prefixLength : ''}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Link</div>
<div class="text-base font-semibold {speedColor(primary.linkSpeedMbps)}">
{speedLabel(primary.linkSpeedMbps)}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Gateway</div>
<div class="text-zinc-300">{primary.gateway ?? '—'}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">DHCP-Server</div>
<div class="text-zinc-300">{primary.dhcpServer ?? '—'}</div>
</div>
<div class="col-span-2">
<div class="text-[10px] uppercase tracking-wide text-zinc-500">DNS</div>
<div class="text-zinc-300">
{primary.dns?.length ? primary.dns.join(', ') : '—'}
</div>
</div>
{#if primary.type === 'wifi'}
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Signal</div>
<div class="font-semibold {rssiColor(primary.rssi)}">
{primary.rssi != null ? primary.rssi + ' dBm' : '—'}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">BSSID</div>
<div class="text-zinc-300 break-all">{primary.bssid ?? '—'}</div>
</div>
{/if}
</div>
<div class="mt-3 flex items-center gap-2">
<button
class="flex items-center gap-1 rounded bg-zinc-800 px-2 py-1.5 text-xs text-zinc-300 active:bg-zinc-700"
onclick={refresh}
>
<RefreshCw size={14} />
Jetzt aktualisieren
</button>
<span class="text-[11px] text-zinc-500">Auto: alle 2 s</span>
<span class="flex-1"></span>
<button
class="rounded bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white active:bg-emerald-700"
onclick={openSave}
>
Speichern…
</button>
</div>
</div>
<!-- Weitere aktive Verbindungen -->
{#if others.length > 0}
<h2 class="mb-1 mt-3 text-sm font-semibold text-zinc-300">Weitere Verbindungen</h2>
{#each others as l (l.iface)}
<div class="mb-1.5 rounded-lg bg-zinc-900 p-2.5 text-xs">
<div class="flex items-center gap-2">
{#if l.type === 'ethernet'}
<Cable size={14} class="text-zinc-400" />
{:else if l.type === 'wifi'}
<Wifi size={14} class="text-zinc-400" />
{:else}
<Signal size={14} class="text-zinc-400" />
{/if}
<span class="font-medium">{typeLabel(l)}</span>
<span class="text-zinc-500">·</span>
<span class="text-zinc-400">{l.ipv4 ?? 'keine IP'}</span>
<span class="flex-1"></span>
<span class={speedColor(l.linkSpeedMbps)}>{speedLabel(l.linkSpeedMbps)}</span>
{#if l.type === 'wifi' && l.rssi != null}
<span class={rssiColor(l.rssi)}>{l.rssi} dBm</span>
{/if}
</div>
</div>
{/each}
{/if}
{/if}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}
{#if saveOpen}
<TextPromptDialog
title="IP-Test speichern"
label="Raum/Dose-Name (optional)"
placeholder="z.B. Büro, Dose 3"
submitLabel="Speichern"
onsubmit={doSave}
oncancel={() => (saveOpen = false)}
/>
{/if}

View file

@ -0,0 +1,412 @@
<script lang="ts">
/**
* WLAN-Empfangstracker — listet WLAN-Netze, beim Klick auf ein Netz wechselt
* die Seite in den Tracker-Modus: laufende RSSI-Anzeige beim Durchlaufen
* durchs Gebäude. Beim verbundenen Netz live (alle 500 ms), bei Fremd-BSSIDs
* scan-basiert (~30 s pro Sample, Android-9+-Limit).
*
* Stop friert eine `WifiTrackSession` ein und legt sie ins Protokoll.
*/
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ChevronDown, RefreshCw, Wifi } from 'lucide-svelte';
import AppHeader from '$lib/components/AppHeader.svelte';
import { getProtocol, saveProtocol } from '$lib/db';
import { addWifiTrackSession, uid } from '$lib/protocols';
import {
scanner,
onWifiSignal,
type WifiNetwork,
type WifiSignalEvent,
} from '$lib/scanner';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import type { Protocol, WifiSignalSample, WifiTrackSession } from '$lib/types';
let protocol = $state<Protocol | null>(null);
let networks = $state<WifiNetwork[]>([]);
let connectedBssid = $state<string | null>(null);
let busy = $state(false);
let scanning = $state(false);
let throttled = $state(false);
let expanded = $state<string | null>(null);
// Aktive Tracker-Session (null wenn keine läuft)
let session = $state<WifiTrackSession | null>(null);
let offSignal: (() => void) | undefined;
const running = $derived(session?.status === 'running');
const pastSessions = $derived(
(protocol?.wifiTrackSessions ?? []).filter((s) => s.status === 'stopped'),
);
onMount(async () => {
const p = await getProtocol($page.params.id ?? '');
if (!p) {
toast.show('Protokoll nicht gefunden', 'error');
goto('/auftraege/');
return;
}
protocol = p;
// Läuft bereits ein Tracker? → wieder andocken
const live = p.wifiTrackSessions?.find((s) => s.status === 'running');
if (live?.runId) {
try {
const st = await scanner.getWifiTrackStatus({ runId: live.runId });
if (st.running) {
live.samples = st.samples;
live.mode = st.mode;
session = live;
attachSignalListener(live.runId);
return;
}
live.status = 'stopped';
live.endedAt = Date.now();
await persist();
} catch {
live.status = 'stopped';
await persist();
}
}
await refreshNetworks();
});
onDestroy(() => {
// Tracker läuft im Plugin (oder im Mock) weiter — nur den Listener lösen
offSignal?.();
void persist();
});
async function persist(): Promise<void> {
if (!protocol) return;
protocol.dirty = true;
await saveProtocol($state.snapshot(protocol) as Protocol);
await sync.refreshPending();
}
async function refreshNetworks() {
if (scanning) return;
scanning = true;
throttled = false;
try {
const triggered = await scanner.startWifiScan().catch(() => ({ triggered: true }));
if (!triggered.triggered) throttled = true;
// Kurz warten, dann den frisch gefüllten Cache lesen (im Mock egal)
await new Promise((r) => setTimeout(r, 800));
const r = await scanner.wifiScan();
networks = [...r.networks].sort((a, b) => b.rssi - a.rssi);
// Aktuell verbundenes Netz aus linkInfo holen
try {
const li = await scanner.linkInfo();
connectedBssid = li.links.find((l) => l.type === 'wifi')?.bssid ?? null;
} catch {
connectedBssid = null;
}
} catch (e) {
toast.show(e instanceof Error ? e.message : 'WLAN-Scan fehlgeschlagen', 'error');
} finally {
scanning = false;
}
}
function attachSignalListener(runId: string) {
offSignal?.();
offSignal = onWifiSignal((e: WifiSignalEvent) => {
if (e.runId !== runId || !session) return;
const s: WifiSignalSample = { ts: e.ts, rssi: e.rssi, source: e.source };
session.samples.push(s);
// Statistik nachziehen
const vals = session.samples.map((x) => x.rssi);
session.min = Math.min(...vals);
session.max = Math.max(...vals);
session.avg = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
void persist();
});
}
async function startTrack(n: WifiNetwork) {
if (!protocol || busy) return;
busy = true;
try {
const { runId, mode } = await scanner.startWifiTrack({
bssid: n.bssid,
intervalMs: 500,
});
const newSession: WifiTrackSession = {
id: uid(),
name: n.ssid || n.bssid,
startedAt: Date.now(),
bssid: n.bssid,
ssid: n.ssid,
band: n.band,
channel: n.channel,
samples: [],
status: 'running',
runId,
mode,
};
addWifiTrackSession(protocol, newSession);
// Den frisch gepushten Eintrag aus dem Array holen (Svelte-Proxy → Reaktivität)
session = protocol.wifiTrackSessions![protocol.wifiTrackSessions!.length - 1];
attachSignalListener(runId);
await persist();
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Tracker-Start fehlgeschlagen', 'error');
} finally {
busy = false;
}
}
async function stopTrack() {
if (!session?.runId || busy) return;
busy = true;
try {
const res = await scanner.stopWifiTrack({ runId: session.runId });
session.samples = res.samples;
session.min = res.min;
session.max = res.max;
session.avg = res.avg;
session.status = 'stopped';
session.endedAt = Date.now();
offSignal?.();
offSignal = undefined;
await persist();
toast.show('Aufzeichnung gespeichert', 'success');
session = null;
} catch (e) {
toast.show(e instanceof Error ? e.message : 'Tracker-Stopp fehlgeschlagen', 'error');
} finally {
busy = false;
}
}
/* --- Anzeige-Helfer --- */
function rssiColor(rssi: number | undefined): string {
if (rssi == null) return 'text-zinc-500';
if (rssi >= -55) return 'text-emerald-400';
if (rssi >= -70) return 'text-amber-400';
return 'text-red-400';
}
function rssiLabel(rssi: number | undefined): string {
if (rssi == null) return '—';
if (rssi >= -55) return 'sehr gut';
if (rssi >= -65) return 'gut';
if (rssi >= -75) return 'mäßig';
return 'schwach';
}
/** Aktuelles RSSI: letztes Sample der laufenden Session */
const currentRssi = $derived(
session && session.samples.length > 0
? session.samples[session.samples.length - 1].rssi
: undefined,
);
/** Letzte 60 Samples für die Sparkline */
const sparklinePoints = $derived.by(() => {
if (!session || session.samples.length === 0) return '';
const tail = session.samples.slice(-60);
const min = Math.min(-85, ...tail.map((s) => s.rssi));
const max = Math.max(-40, ...tail.map((s) => s.rssi));
const w = 280;
const h = 50;
return tail
.map((s, i) => {
const x = (i / Math.max(1, tail.length - 1)) * w;
const y = h - ((s.rssi - min) / Math.max(1, max - min)) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
function fmtTime(ts: number): string {
return new Date(ts).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function fmtDateTime(ts: number): string {
return new Date(ts).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
{#if protocol}
<AppHeader
title={running ? 'Empfangstracker' : 'WLAN-Empfangstracker'}
subtitle={running ? (session?.ssid ?? '') : protocol.label}
back
/>
<div class="flex-1 overflow-y-auto p-3">
{#if running && session}
<!-- Tracker-Detail-Ansicht -->
<div class="rounded-lg bg-zinc-900 p-4">
<div class="flex items-center justify-between">
<span class="text-xs text-zinc-500">
{session.mode === 'connected'
? 'Live (verbundenes Netz, 0,5 s)'
: 'Scan-Modus (~30 s · Android-Limit)'}
</span>
<span class="text-xs text-zinc-500">Kanal {session.channel} · {session.band}</span>
</div>
<div class="mt-2 flex items-baseline gap-2">
<span class="text-5xl font-bold {rssiColor(currentRssi)} tabular-nums">
{currentRssi ?? '—'}
</span>
<span class="text-lg text-zinc-400">dBm</span>
<span class="text-sm {rssiColor(currentRssi)}">{rssiLabel(currentRssi)}</span>
</div>
<!-- Sparkline der letzten 60 Samples -->
{#if sparklinePoints}
<svg viewBox="0 0 280 50" class="mt-2 w-full" preserveAspectRatio="none">
<polyline
points={sparklinePoints}
fill="none"
stroke="currentColor"
stroke-width="1.5"
class={rssiColor(currentRssi)}
/>
</svg>
{/if}
<div class="mt-2 grid grid-cols-3 gap-2 text-xs">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Min</div>
<div class={rssiColor(session.min)}>{session.min ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ø</div>
<div class={rssiColor(session.avg)}>{session.avg ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Max</div>
<div class={rssiColor(session.max)}>{session.max ?? '—'} dBm</div>
</div>
</div>
<p class="mt-2 text-[11px] text-zinc-500">
{session.samples.length} Samples · seit {fmtTime(session.startedAt)}
</p>
<button
class="mt-3 w-full rounded-lg bg-red-600 py-2 text-sm font-semibold text-white active:bg-red-700 disabled:opacity-50"
onclick={stopTrack}
disabled={busy}
>
Aufzeichnung beenden
</button>
</div>
{:else}
<!-- Listen-Ansicht (Netzwahl) -->
<div class="flex items-center gap-2">
<h2 class="flex-1 text-sm font-semibold text-zinc-300">WLAN auswählen</h2>
<button
class="flex items-center gap-1 rounded bg-zinc-800 px-2 py-1.5 text-xs text-zinc-300 active:bg-zinc-700 disabled:opacity-50"
onclick={refreshNetworks}
disabled={scanning}
>
<RefreshCw size={14} class={scanning ? 'animate-spin' : ''} />
Scan
</button>
</div>
{#if throttled}
<p class="mt-1 text-[11px] leading-tight text-amber-400">
Android-Scan-Throttling — letztes Cache-Ergebnis wird gezeigt. In 2 Min nochmal.
</p>
{/if}
{#if networks.length === 0}
<p class="mt-3 text-sm text-zinc-500">Noch keine Netze — bitte Scan starten.</p>
{:else}
<div class="mt-2 flex flex-col gap-1">
{#each networks as n (n.bssid)}
{@const isConnected = n.bssid === connectedBssid}
<button
class="flex items-center gap-2 rounded-lg bg-zinc-800 px-2.5 py-2 text-left active:bg-zinc-700 disabled:opacity-50"
onclick={() => startTrack(n)}
disabled={busy}
>
<Wifi size={16} class={rssiColor(n.rssi)} />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{n.ssid}</div>
<div class="text-[11px] text-zinc-500">
Kanal {n.channel} · {n.band}
{#if isConnected}
· <span class="text-emerald-400">verbunden — Live-Tracking</span>
{/if}
</div>
</div>
<span class="shrink-0 text-xs {rssiColor(n.rssi)} tabular-nums">{n.rssi} dBm</span>
</button>
{/each}
</div>
<p class="mt-2 text-[11px] leading-tight text-zinc-500">
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).
</p>
{/if}
{/if}
<!-- Frühere Tracker-Sessions -->
{#if !running && pastSessions.length > 0}
<h2 class="mb-1 mt-4 text-sm font-semibold text-zinc-300">Frühere Aufzeichnungen</h2>
{#each pastSessions as s (s.id)}
<div class="mb-1.5 rounded-lg bg-zinc-900">
<button
class="flex w-full items-center justify-between gap-2 p-2.5 text-left"
onclick={() => (expanded = expanded === s.id ? null : s.id)}
>
<div class="min-w-0">
<div class="truncate text-sm font-medium">{s.name}</div>
<div class="text-[11px] text-zinc-500">
{s.samples.length} Samples · Ø {s.avg ?? '—'} dBm ·
{fmtDateTime(s.startedAt)}
</div>
</div>
<ChevronDown
size={16}
class="shrink-0 text-zinc-500 {expanded === s.id ? 'rotate-180' : ''}"
/>
</button>
{#if expanded === s.id}
<div class="border-t border-zinc-800 p-2.5 text-xs">
<div class="grid grid-cols-3 gap-2">
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Min</div>
<div class={rssiColor(s.min)}>{s.min ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Ø</div>
<div class={rssiColor(s.avg)}>{s.avg ?? '—'} dBm</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-zinc-500">Max</div>
<div class={rssiColor(s.max)}>{s.max ?? '—'} dBm</div>
</div>
</div>
<p class="mt-1 text-[11px] text-zinc-500">
BSSID {s.bssid} · Kanal {s.channel} · {s.band}
</p>
</div>
{/if}
</div>
{/each}
{/if}
</div>
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}