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.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/<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 */
/* --------------------------------------------------------------------- */

View file

@ -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/<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 */
/* --------------------------------------------------------------------- */

View file

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

View file

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

View file

@ -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<NetDiagScannerPlugin>('NetDiagScanner');
@ -157,6 +187,14 @@ const monitorListeners = new Set<(e: MonitorEventData) => void>();
let mockMonitorTimer: ReturnType<typeof setInterval> | undefined;
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 = {
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<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 { 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,

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;
}
/** 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 */
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;

View file

@ -278,6 +278,28 @@
Erreichbarkeit mehrerer Geräte dauerhaft überwachen.
</span>
</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>
</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}