Phase 6: IP-Test (Dose prüfen) und WLAN-Empfangstracker [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m49s
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:
parent
d2df3ee929
commit
3c95ff6b07
12 changed files with 1600 additions and 73 deletions
|
|
@ -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 */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function normalizeProtocol(p: Protocol): Protocol {
|
|||
p.measurements ??= [];
|
||||
p.savedScans ??= [];
|
||||
p.monitorSessions ??= [];
|
||||
p.wifiTrackSessions ??= [];
|
||||
return p;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
266
src/routes/protokoll/[id]/iptest/+page.svelte
Normal file
266
src/routes/protokoll/[id]/iptest/+page.svelte
Normal 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}
|
||||
412
src/routes/protokoll/[id]/wifi/+page.svelte
Normal file
412
src/routes/protokoll/[id]/wifi/+page.svelte
Normal 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}
|
||||
Loading…
Reference in a new issue