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 import android.net.wifi.WifiManager import android.os.Build import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.content.FileProvider import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.Permission import com.getcapacitor.annotation.PermissionCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.File import java.io.FileOutputStream import java.io.FileReader import java.net.DatagramPacket import java.net.DatagramSocket import java.net.HttpURLConnection import java.net.Inet4Address import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.net.URL import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit /** * NetDiagScanner — natives Scan-Plugin der NetDiag-App. * * Der WebView kann keine Raw-Sockets/ICMP/ARP. Diese Klasse führt die * eigentlichen Netzwerk-Messungen durch und wird vom TS-Wrapper * (src/lib/scanner.ts) über `registerPlugin('NetDiagScanner')` angesprochen. * * Integration: Datei nach * android/app/src/main/java/de/data_it_solution/netdiag/ * kopieren und in MainActivity registrieren: * registerPlugin(NetDiagScannerPlugin::class.java) */ @CapacitorPlugin( name = "NetDiagScanner", permissions = [ Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION]) ] ) class NetDiagScannerPlugin : Plugin() { private val io = CoroutineScope(Dispatchers.IO) private val stressRuns = ConcurrentHashMap() /* --------------------------------------------------------------------- */ /* Subnetz / lokale Netzwerkinfo */ /* --------------------------------------------------------------------- */ @PluginMethod fun getLocalSubnet(call: PluginCall) { io.launch { try { var ip = "" var prefix = 0 var gateway = "" // 1. Aktives Netz (WLAN ODER Ethernet) über LinkProperties — // liefert das ECHTE Präfix, nicht pauschal /24. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val cm = context.applicationContext .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val lp = cm.activeNetwork?.let { cm.getLinkProperties(it) } if (lp != null) { for (la in lp.linkAddresses) { val a = la.address if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { ip = a.hostAddress ?: "" prefix = la.prefixLength break } } for (route in lp.routes) { val gw = route.gateway if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { gateway = gw.hostAddress ?: "" break } } } } // 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer) if (ip.isEmpty() || prefix == 0) { val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo if (dhcp != null) { if (ip.isEmpty() && dhcp.ipAddress != 0) ip = intToIp(dhcp.ipAddress) if (prefix == 0 && dhcp.netmask != 0) prefix = Integer.bitCount(dhcp.netmask) if (gateway.isEmpty() && dhcp.gateway != 0) gateway = intToIp(dhcp.gateway) } } // 3. Letzter Fallback if (ip.isEmpty()) ip = firstLocalIpv4() if (prefix !in 1..32) prefix = 24 // Netzadresse aus IP + Präfix berechnen val ipInt = ipv4ToInt(ip) ?: 0 val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val network = intToIpv4(ipInt and mask) resolve(call, JSObject() .put("subnet", "$network/$prefix") .put("ip", ip) .put("gateway", gateway)) } catch (e: Exception) { call.reject("getLocalSubnet: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* IP-Scan: Geräte im Subnetz finden */ /* --------------------------------------------------------------------- */ @PluginMethod fun ipScan(call: PluginCall) { val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val hosts = hostsInSubnet(subnet) if (hosts.isEmpty()) { return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") } io.launch { try { // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). val alive = withContext(Dispatchers.IO) { hosts.map { ipInt -> async { val ip = intToIpv4(ipInt) if (InetAddress.getByName(ip).isReachable(350)) ip else null } }.awaitAll().filterNotNull() } val arp = readArpTable() // Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name, // Quick-Port-Probe (für die Geräteart-Heuristik). val enriched = withContext(Dispatchers.IO) { alive.map { ip -> async { val hostname = try { val n = InetAddress.getByName(ip).canonicalHostName if (n != ip) n else "" } catch (_: Exception) { "" } EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip)) } }.awaitAll() } val devices = JSArray() for (h in enriched) { val dev = JSObject().put("ip", h.ip) val mac = arp[h.ip] val vendor = mac?.let { ouiVendor(it) } ?: "" if (mac != null) dev.put("mac", mac) if (vendor.isNotEmpty()) dev.put("vendor", vendor) if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname) if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios) if (h.openPorts.isNotEmpty()) { val pa = JSArray() h.openPorts.sorted().forEach { pa.put(it) } dev.put("openPorts", pa) } val nameHint = h.hostname.ifEmpty { h.netbios ?: "" } val type = guessDeviceType(vendor, nameHint, h.openPorts) if (type.isNotEmpty()) dev.put("deviceType", type) devices.put(dev) } resolve(call, JSObject().put("devices", devices)) } catch (e: Exception) { call.reject("ipScan: ${e.message}") } } } /** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */ private data class EnrichedHost( val ip: String, val hostname: String, val netbios: String?, val openPorts: List, ) /** * Schneller TCP-Connect-Test auf einige Schlüsselports — speist die * Geräteart-Heuristik (z.B. 554 → Kamera, 9100 → Drucker). 500 ms Timeout. */ private suspend fun quickPortProbe(ip: String): List { val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100) return withContext(Dispatchers.IO) { probe.map { port -> async { try { Socket().use { it.connect(InetSocketAddress(ip, port), 500) } port } catch (_: Exception) { null } } }.awaitAll().filterNotNull() } } /** * NetBIOS-Namensabfrage (NBSTAT, UDP 137) — liefert den Workstation-Namen * vieler Windows-Rechner, NAS-Geräte und Kameras. Kein Root nötig. */ private fun netbiosName(ip: String): String? { // NBSTAT-„Node Status Request" für den Wildcard-Namen "*" (50 Byte). val query = byteArrayOf( 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x43, 0x4B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x00, 0x00, 0x21, 0x00, 0x01, ) return try { DatagramSocket().use { sock -> sock.soTimeout = 600 sock.send(DatagramPacket(query, query.size, InetAddress.getByName(ip), 137)) val buf = ByteArray(512) val resp = DatagramPacket(buf, buf.size) sock.receive(resp) val data = resp.data if (resp.length < 57) return null val numNames = data[56].toInt() and 0xFF for (i in 0 until numNames) { val base = 57 + i * 18 if (base + 18 > resp.length) break val suffix = data[base + 15].toInt() and 0xFF val isGroup = (data[base + 16].toInt() and 0x80) != 0 // Suffix 0x00 + kein Gruppen-Flag = der eigentliche Gerätename if (suffix == 0x00 && !isGroup) { val name = String(data, base, 15, Charsets.US_ASCII).trim() if (name.isNotEmpty()) return name } } null } } catch (_: Exception) { null } } /** * Geräteart aus Hersteller, Name und offenen Ports schätzen. * Best-Effort-Heuristik — leerer String, wenn nichts Eindeutiges erkennbar. */ private fun guessDeviceType(vendor: String, name: String, ports: List): String { val v = vendor.lowercase() val n = name.lowercase() // 1. Eindeutige Hersteller if (v.contains("axis") || v.contains("hikvision") || v.contains("dahua")) return "Kamera" if (v.contains("avm")) return "Router" if (v.contains("sonos")) return "Lautsprecher" if (v.contains("synology") || v.contains("qnap")) return "NAS" if (v.contains("raspberry")) return "Raspberry Pi" if (v.contains("espressif")) return "IoT-Gerät" // 2. Namensmuster if (n.contains("camera") || n.contains("kamera") || n.contains("ipcam") || n.contains("nvr") || n.contains("axis") || n.contains("hikvision")) return "Kamera" if (n.contains("printer") || n.contains("drucker")) return "Drucker" if (n.contains("fritz") || n.contains("router") || n.contains("gateway")) return "Router" if (n.contains("switch")) return "Switch" if (n.contains("wallbox") || n.contains("keba") || n.contains("charger")) return "Wallbox" if (n.contains("nas") || n.contains("synology") || n.contains("diskstation")) return "NAS" // 3. Portmuster return when { 554 in ports -> "Kamera" 9100 in ports || 515 in ports -> "Drucker" 3389 in ports || (445 in ports && 1883 !in ports) -> "Windows-PC" 1883 in ports || 502 in ports -> "IoT/SPS" 22 in ports && (80 in ports || 443 in ports) -> "Server" 22 in ports -> "Linux-Gerät" else -> "" } } /* --------------------------------------------------------------------- */ /* mDNS / Bonjour — Drucker, Kameras, Chromecast, AirPlay … */ /* --------------------------------------------------------------------- */ /** * mDNS-Dienstsuche über NsdManager. Liefert pro IP den Bonjour-Namen und * die angebotenen Diensttypen. Kein Root, keine Berechtigung nötig — nur * ein Multicast-Lock fürs WLAN. Ergebnis ist Best-Effort (Timeout-begrenzt). */ @PluginMethod fun mdnsScan(call: PluginCall) { val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong() io.launch { try { val found = discoverMdns(timeoutMs) val arr = JSArray() for ((ip, info) in found) { val services = JSArray() info.services.forEach { services.put(it) } arr.put(JSObject() .put("ip", ip) .put("name", info.name) .put("services", services)) } resolve(call, JSObject().put("devices", arr)) } catch (e: Exception) { call.reject("mdnsScan: ${e.message}") } } } private class MdnsInfo { var name: String = "" val services: MutableSet = ConcurrentHashMap.newKeySet() } @Suppress("DEPRECATION") private fun discoverMdns(timeoutMs: Long): Map { val nsd = context.applicationContext .getSystemService(Context.NSD_SERVICE) as NsdManager val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager val types = listOf( "_printer._tcp.", "_ipp._tcp.", "_pdl-datastream._tcp.", "_googlecast._tcp.", "_airplay._tcp.", "_raop._tcp.", "_http._tcp.", "_workstation._tcp.", "_smb._tcp.", "_rtsp._tcp.", "_axis-video._tcp.", "_hap._tcp.", "_ssh._tcp.", ) val result = ConcurrentHashMap() val pending = ConcurrentLinkedQueue() val listeners = ArrayList() val mlock = wifi.createMulticastLock("netdiag-mdns").apply { setReferenceCounted(true) try { acquire() } catch (_: Exception) { } } try { for (type in types) { val l = object : NsdManager.DiscoveryListener { override fun onStartDiscoveryFailed(s: String?, e: Int) {} override fun onStopDiscoveryFailed(s: String?, e: Int) {} override fun onDiscoveryStarted(s: String?) {} override fun onDiscoveryStopped(s: String?) {} override fun onServiceFound(info: NsdServiceInfo) { pending.add(info) } override fun onServiceLost(info: NsdServiceInfo) {} } try { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l) listeners.add(l) } catch (_: Exception) { } } // Gefundene Dienste seriell auflösen — NsdManager.resolveService // verträgt keine parallelen Aufrufe. val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { val info = pending.poll() if (info == null) { Thread.sleep(100) continue } val lock = CountDownLatch(1) try { nsd.resolveService(info, object : NsdManager.ResolveListener { override fun onResolveFailed(s: NsdServiceInfo?, e: Int) { lock.countDown() } override fun onServiceResolved(s: NsdServiceInfo) { val host = s.host?.hostAddress if (host != null) { val mi = result.getOrPut(host) { MdnsInfo() } if (mi.name.isEmpty()) mi.name = s.serviceName ?: "" val t = (s.serviceType ?: "").trim('.', ' ') if (t.isNotEmpty()) mi.services.add(t) } lock.countDown() } }) lock.await(1500, TimeUnit.MILLISECONDS) } catch (_: Exception) { } } } finally { for (l in listeners) { try { nsd.stopServiceDiscovery(l) } catch (_: Exception) { } } try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { } } return result } /** * Alle Host-IPs (als Int) eines CIDR-Subnetzes. * "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw. * Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind * ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16. */ private fun hostsInSubnet(cidr: String): List { val parts = cidr.trim().split('/') val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList() val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24 if (prefix < 0 || prefix > 32) return emptyList() val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val network = ipInt and mask val broadcast = network or mask.inv() val out = ArrayList() if (prefix >= 31) { var i = network while (true) { out.add(i); if (i == broadcast) break; i++ } return out } val count = (broadcast.toLong() and 0xFFFFFFFFL) - (network.toLong() and 0xFFFFFFFFL) - 1L if (count < 1L || count > 65534L) return emptyList() var i = network + 1 val last = broadcast - 1 while (true) { out.add(i); if (i == last) break; i++ } return out } /** "192.168.1.50" -> 32-Bit-Int (big-endian), null bei ungueltig */ private fun ipv4ToInt(s: String): Int? { val o = s.split('.') if (o.size != 4) return null var v = 0 for (part in o) { val n = part.toIntOrNull() ?: return null if (n < 0 || n > 255) return null v = (v shl 8) or n } return v } /** 32-Bit-Int (big-endian) -> "192.168.1.50" */ private fun intToIpv4(i: Int): String = "${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}" /* --------------------------------------------------------------------- */ /* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */ /* --------------------------------------------------------------------- */ /** * Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden. * * Ohne Root lässt sich kein Roh-ARP mitschneiden — der praktikable Weg: * über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine * ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für * dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte * diese Adresse → Konflikt. * * Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein — * dann meldet das Ergebnis `arpAvailable = false`. */ @PluginMethod fun arpConflictScan(call: PluginCall) { val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10) val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong() val hosts = hostsInSubnet(subnet) if (hosts.isEmpty()) { return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") } io.launch { try { val seen = HashMap>() var arpEverFilled = false for (r in 0 until rounds) { withContext(Dispatchers.IO) { hosts.map { ipInt -> async { try { InetAddress.getByName(intToIpv4(ipInt)).isReachable(300) } catch (_: Exception) { false } } }.awaitAll() } val arp = readArpTable() if (arp.isNotEmpty()) arpEverFilled = true for ((ip, mac) in arp) { seen.getOrPut(ip) { HashSet() }.add(mac) } if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs) } val conflicts = JSArray() for ((ip, macs) in seen) { if (macs.size > 1) { val macArr = JSArray() macs.forEach { macArr.put(it) } conflicts.put(JSObject().put("ip", ip).put("macs", macArr)) } } resolve(call, JSObject() .put("conflicts", conflicts) .put("checked", seen.size) .put("rounds", rounds) .put("arpAvailable", arpEverFilled)) } catch (e: Exception) { call.reject("arpConflictScan: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Port-Scan */ /* --------------------------------------------------------------------- */ @PluginMethod fun portScan(call: PluginCall) { val ip = call.getString("ip") ?: return call.reject("ip fehlt") val portsArg = call.getArray("ports") ?: JSArray() val ports = (0 until portsArg.length()).map { portsArg.getInt(it) } io.launch { try { val open = withContext(Dispatchers.IO) { ports.map { port -> async { try { Socket().use { s -> s.connect(InetSocketAddress(ip, port), 700) } port } catch (_: Exception) { null } } }.awaitAll().filterNotNull() } val arr = JSArray() for (p in open) arr.put(JSObject().put("port", p).put("service", serviceName(p))) resolve(call, JSObject().put("open", arr)) } catch (e: Exception) { call.reject("portScan: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Ping-Qualität */ /* --------------------------------------------------------------------- */ @PluginMethod fun pingQuality(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val count = call.getInt("count") ?: 20 io.launch { try { resolve(call, measurePing(host, count)) } catch (e: Exception) { call.reject("pingQuality: ${e.message}") } } } private fun measurePing(host: String, count: Int): JSObject { val times = ArrayList() val addr = InetAddress.getByName(host) repeat(count) { val t0 = System.nanoTime() if (addr.isReachable(1000)) { times.add((System.nanoTime() - t0) / 1_000_000.0) } Thread.sleep(200) } val received = times.size val loss = ((count - received) * 100) / count val min = times.minOrNull() ?: 0.0 val max = times.maxOrNull() ?: 0.0 val avg = if (times.isNotEmpty()) times.average() else 0.0 // Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte var jitter = 0.0 for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1]) if (times.size > 1) jitter /= (times.size - 1) return JSObject() .put("sent", count).put("received", received).put("lossPct", loss) .put("minMs", round1(min)).put("avgMs", round1(avg)) .put("maxMs", round1(max)).put("jitterMs", round1(jitter)) } /* --------------------------------------------------------------------- */ /* WLAN-Scan */ /* --------------------------------------------------------------------- */ @PluginMethod fun wifiScan(call: PluginCall) { if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { requestPermissionForAlias("location", call, "wifiScanPermCallback") return } doWifiScan(call) } @PermissionCallback private fun wifiScanPermCallback(call: PluginCall) { if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { doWifiScan(call) } else { call.reject("Standortberechtigung für WLAN-Scan abgelehnt") } } private fun doWifiScan(call: PluginCall) { try { val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val arr = JSArray() for (r in wifi.scanResults) { val freq = r.frequency arr.put(JSObject() .put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID) .put("bssid", r.BSSID ?: "") .put("channel", freqToChannel(freq)) .put("rssi", r.level) .put("band", if (freq > 4000) "5 GHz" else "2.4 GHz")) } resolve(call, JSObject().put("networks", arr)) } catch (e: Exception) { call.reject("wifiScan: ${e.message}") } } /* --------------------------------------------------------------------- */ /* DHCP-Info — DHCP-Server, von dem das Gerät seine Adresse bezieht */ /* --------------------------------------------------------------------- */ /** * Liefert den DHCP-Server, von dem das Gerät selbst seine IP bezogen hat, * samt Lease-Dauer, Gateway und DNS. * * Eine aktive Rogue-DHCP-Suche (DHCPDISCOVER senden, OFFER empfangen) ist * auf nicht gerootetem Android NICHT möglich: der Server antwortet auf * UDP-Port 68, der ist privilegiert (< 1024) und vom System-DHCP-Client * belegt. Darum hier nur die verlässliche, vom OS bezogene Lease-Info. */ @PluginMethod fun dhcpInfo(call: PluginCall) { io.launch { try { val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo val out = JSObject() val dns = JSArray() if (dhcp != null && dhcp.serverAddress != 0) { out.put("server", intToIp(dhcp.serverAddress)) out.put("lease", dhcp.leaseDuration) out.put("gateway", if (dhcp.gateway != 0) intToIp(dhcp.gateway) else "") if (dhcp.dns1 != 0) dns.put(intToIp(dhcp.dns1)) if (dhcp.dns2 != 0) dns.put(intToIp(dhcp.dns2)) } else { // Kein WLAN-DHCP greifbar (z. B. Ethernet) — Server nicht ermittelbar out.put("server", "") out.put("lease", 0) out.put("gateway", "") } out.put("dns", dns) resolve(call, out) } catch (e: Exception) { call.reject("dhcpInfo: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* SNMP v2c GET (Switch: Link-Speed, Fehlerzähler) */ /* --------------------------------------------------------------------- */ @PluginMethod fun snmpGet(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val community = call.getString("community") ?: "public" val oidsArg = call.getArray("oids") ?: JSArray() val oids = (0 until oidsArg.length()).map { oidsArg.getString(it) } io.launch { try { val values = JSObject() for (oid in oids) { values.put(oid, Snmp.get(host, community, oid) ?: "-") } resolve(call, JSObject().put("values", values)) } catch (e: Exception) { call.reject("snmpGet: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Traceroute (über das System-ping-Binary, kein Root nötig) */ /* --------------------------------------------------------------------- */ @PluginMethod fun traceroute(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") io.launch { try { val hops = JSArray() var deadStreak = 0 for (ttl in 1..30) { val hop = pingWithTtl(host, ttl) hops.put(JSObject() .put("ttl", ttl) .put("ip", hop.ip) .put("ms", hop.ms)) if (hop.ip == host || hop.reachedTarget) break // Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30 deadStreak = if (hop.ip == "*") deadStreak + 1 else 0 if (deadStreak >= 5) break } resolve(call, JSObject().put("hops", hops)) } catch (e: Exception) { call.reject("traceroute: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Durchsatz-Test */ /* --------------------------------------------------------------------- */ @PluginMethod fun throughput(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val port = call.getInt("port") ?: 5201 val durationSec = call.getInt("durationSec") ?: 10 io.launch { try { // Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle: // Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde. val res = measureThroughput(host, port, durationSec) resolve(call, res) } catch (e: Exception) { call.reject("throughput: ${e.message}") } } } private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject { val buf = ByteArray(64 * 1024) var downBytes = 0L var upBytes = 0L // Upload-Phase Socket().use { s -> s.connect(InetSocketAddress(host, port), 3000) val end = System.currentTimeMillis() + durationSec * 500L val out = s.getOutputStream() while (System.currentTimeMillis() < end) { out.write(buf); upBytes += buf.size } } // Download-Phase Socket().use { s -> s.connect(InetSocketAddress(host, port), 3000) val end = System.currentTimeMillis() + durationSec * 500L val inp = s.getInputStream() while (System.currentTimeMillis() < end) { val n = inp.read(buf); if (n < 0) break; downBytes += n } } val secs = durationSec / 2.0 return JSObject() .put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs)) .put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs)) } /* --------------------------------------------------------------------- */ /* Dauer-/Stresstest */ /* --------------------------------------------------------------------- */ @PluginMethod fun startStressTest(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val durationSec = call.getInt("durationSec") ?: 300 val runId = "run-${System.currentTimeMillis()}" val run = StressRun(host, durationSec) stressRuns[runId] = run // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service // gestartet werden, sonst kann Android den Prozess beenden. io.launch { val end = System.currentTimeMillis() + durationSec * 1000L while (System.currentTimeMillis() < end && run.active) { val q = measurePing(host, 5) run.samples++ run.lossSum += q.getInteger("lossPct", 0) ?: 0 run.avgSum += q.getDouble("avgMs") run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) } } resolve(call, JSObject().put("runId", runId)) } @PluginMethod fun stopStressTest(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") run.active = false val n = Math.max(1, run.samples) resolve(call, JSObject() .put("samples", run.samples) .put("lossPct", run.lossSum / n) .put("avgMs", round1(run.avgSum / n)) .put("maxMs", round1(run.maxMs))) } private class StressRun(val host: String, val durationSec: Int) { var active = true var samples = 0 var lossSum = 0 var avgSum = 0.0 var maxMs = 0.0 } /* --------------------------------------------------------------------- */ /* Geräte-Monitor — Dauerüberwachung mehrerer Geräte (Kamera-Problem) */ /* --------------------------------------------------------------------- */ private val monitorRuns = ConcurrentHashMap() private class MonitorRun( val targets: List>, val intervalSec: Int, ) { @Volatile var active = true /** je IP: true = erreichbar */ val state = ConcurrentHashMap() /** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */ val downSince = ConcurrentHashMap() /** alle bisher erzeugten Ereignisse — für die UI-Wiederaufnahme */ val events = java.util.concurrent.CopyOnWriteArrayList() } /** * Überwacht die Erreichbarkeit mehrerer Geräte im festen Intervall. * Jeder Wechsel erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`. * Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel), * ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft. */ @PluginMethod fun startMonitor(call: PluginCall) { val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt") val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600) val targets = ArrayList>() for (i in 0 until arr.length()) { val o = arr.optJSONObject(i) ?: continue val ip = o.optString("ip") if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip)) } if (targets.isEmpty()) return call.reject("keine Geräte gewählt") val runId = "mon-${System.currentTimeMillis()}" val run = MonitorRun(targets, intervalSec) monitorRuns[runId] = run MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s") @Suppress("DEPRECATION") val wifiLock = (context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager) .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor") try { wifiLock.acquire() } catch (_: Exception) { } io.launch { try { // Ausgangslage erfassen — erzeugt noch kein Ereignis for ((ip, _) in targets) run.state[ip] = isReachable(ip) while (run.active) { Thread.sleep(intervalSec * 1000L) if (!run.active) break for ((ip, label) in targets) { if (!run.active) break val up = isReachable(ip) val prev = run.state[ip] ?: up if (up == prev) continue run.state[ip] = up val now = System.currentTimeMillis() val ev = JSObject() .put("runId", runId) .put("ip", ip) .put("label", label) .put("ts", now) if (up) { val since = run.downSince.remove(ip) ev.put("type", "up") ev.put( "durationSec", if (since != null) ((now - since) / 1000L).toInt() else 0, ) } else { run.downSince[ip] = now ev.put("type", "down") notifyDown(label, ip) } run.events.add(ev) notifyListeners("monitorEvent", ev) } } } finally { try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { } } } resolve(call, JSObject().put("runId", runId)) } @PluginMethod fun stopMonitor(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") run.active = false if (monitorRuns.isEmpty()) MonitorService.stop(context) val events = JSArray() run.events.forEach { events.put(it) } resolve(call, JSObject().put("stopped", true).put("events", events)) } /** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */ @PluginMethod fun getMonitorStatus(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = monitorRuns[runId] val events = JSArray() run?.events?.forEach { events.put(it) } resolve(call, JSObject() .put("running", run != null && run.active) .put("events", events)) } private fun isReachable(ip: String): Boolean = try { InetAddress.getByName(ip).isReachable(1500) } catch (_: Exception) { false } /** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */ private fun notifyDown(label: String, ip: String) { try { MonitorService.ensureChannel(context) val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID) .setContentTitle("Gerät nicht erreichbar") .setContentText("$label ($ip) antwortet nicht mehr") .setSmallIcon(android.R.drawable.stat_notify_error) .setAutoCancel(true) .build() mgr.notify(ip.hashCode(), n) } catch (_: Exception) { } } /* --------------------------------------------------------------------- */ /* IP-Test — alle aktiven Netzwerk-Interfaces (WLAN, Ethernet, USB-RJ45) */ /* --------------------------------------------------------------------- */ /** * Listet alle aktiven Netzwerk-Interfaces des Geräts. Für jedes Interface * werden IP, Präfix, Gateway, DNS, DHCP-Server (nur WLAN) und Link-Speed * geliefert. Bei USB-RJ45-Adaptern wird die Speed aus * /sys/class/net//speed gelesen — funktioniert ohne Root. * * Anwendungsfall: Adapter in eine Netzwerkdose stecken und sofort sehen, * ob eine IP kommt und ob der Link 10/100/1000 Mbit ausspuckt. */ @PluginMethod fun linkInfo(call: PluginCall) { io.launch { try { val cm = context.applicationContext .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager val defaultIface = cm.activeNetwork?.let { cm.getLinkProperties(it)?.interfaceName } val arr = JSArray() val networks: Array = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) cm.allNetworks else emptyArray() for (net in networks) { val caps = cm.getNetworkCapabilities(net) ?: continue val lp = cm.getLinkProperties(net) ?: continue val iface = lp.interfaceName ?: continue val type = when { caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" else -> "other" } // IPv4 + Präfix aus den LinkAddresses var ipv4: String? = null var prefix = 0 for (la in lp.linkAddresses) { val a = la.address if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { ipv4 = a.hostAddress prefix = la.prefixLength break } } var gateway: String? = null for (route in lp.routes) { val gw = route.gateway if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { gateway = gw.hostAddress break } } val dnsArr = JSArray() for (d in lp.dnsServers) { if (d is Inet4Address) d.hostAddress?.let { dnsArr.put(it) } } val obj = JSObject() .put("iface", iface) .put("type", type) .put("isDefault", iface == defaultIface) if (ipv4 != null) obj.put("ipv4", ipv4) if (prefix > 0) obj.put("prefixLength", prefix) if (gateway != null) obj.put("gateway", gateway) obj.put("dns", dnsArr) // USB-RJ45-Heuristik val ifLower = iface.lowercase() val isUsb = ifLower.startsWith("rndis") || ifLower.startsWith("usb") || ifLower.startsWith("ecm") || (type == "ethernet" && ifLower.matches(Regex("eth[1-9].*"))) if (isUsb) obj.put("isUsbEthernet", true) // Link-Speed-Quellen if (type == "wifi") { try { @Suppress("DEPRECATION") val ci = wifi.connectionInfo if (ci != null) { if (ci.linkSpeed > 0) obj.put("linkSpeedMbps", ci.linkSpeed) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && ci.rxLinkSpeedMbps > 0) { obj.put("rxLinkSpeedMbps", ci.rxLinkSpeedMbps) } val ssid = ci.ssid?.trim('"') if (!ssid.isNullOrEmpty() && ssid != "") { obj.put("ssid", ssid) } if (!ci.bssid.isNullOrEmpty()) obj.put("bssid", ci.bssid) if (ci.rssi != -127) obj.put("rssi", ci.rssi) } @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo if (dhcp != null && dhcp.serverAddress != 0) { obj.put("dhcpServer", intToIp(dhcp.serverAddress)) if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration) } } catch (_: Exception) { /* WLAN-Info nicht greifbar */ } } else if (type == "ethernet") { val mbps = readEthernetSpeed(iface) if (mbps != null) obj.put("linkSpeedMbps", mbps) } arr.put(obj) } resolve(call, JSObject().put("links", arr)) } catch (e: Exception) { call.reject("linkInfo: ${e.message}") } } } /** * Link-Speed eines Ethernet-Interfaces aus /sys/class/net//speed lesen. * Liefert Mbit/s als Int (z.B. 1000 für Gigabit), oder null wenn nicht lesbar * (auf manchen Android-12+-Geräten ist /sys/class/net/ selektiv gesperrt). */ private fun readEthernetSpeed(iface: String): Int? { return try { val f = File("/sys/class/net/$iface/speed") if (!f.exists() || !f.canRead()) return null val raw = f.readText().trim() val n = raw.toIntOrNull() ?: return null if (n <= 0) null else n } catch (_: Exception) { null } } /* --------------------------------------------------------------------- */ /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ /* --------------------------------------------------------------------- */ private val wifiTrackRuns = ConcurrentHashMap() private class WifiTrackRun( val bssid: String, val mode: String, // "connected" | "scan" val intervalMs: Int, ) { @Volatile var active = true val samples = java.util.concurrent.CopyOnWriteArrayList() var scanReceiver: BroadcastReceiver? = null } /** * Aktiven WLAN-Scan triggern (System legt frische Ergebnisse in den Cache). * Liefert `triggered=false` bei Android-Throttling (ab API 28: max. 4 * Foreground-Scans / 2 Min). */ @PluginMethod fun startWifiScan(call: PluginCall) { if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { requestPermissionForAlias("location", call, "startWifiScanPermCallback") return } doStartWifiScan(call) } @PermissionCallback private fun startWifiScanPermCallback(call: PluginCall) { if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { doStartWifiScan(call) } else { call.reject("Standortberechtigung für WLAN-Scan abgelehnt") } } private fun doStartWifiScan(call: PluginCall) { try { val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val triggered = wifi.startScan() resolve(call, JSObject().put("triggered", triggered)) } catch (e: Exception) { call.reject("startWifiScan: ${e.message}") } } /** * Empfangs-Tracker für ein BSSID starten. Wenn das BSSID das aktuell * verbundene Netz ist: Live-RSSI aus `WifiManager.connectionInfo` alle * `intervalMs` (kein Scan-Throttling). Sonst: periodischer `startScan` * alle ~30 s, BroadcastReceiver lauscht auf `SCAN_RESULTS_AVAILABLE_ACTION` * und liefert den Wert des passenden BSSIDs. */ @PluginMethod fun startWifiTrack(call: PluginCall) { val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { requestPermissionForAlias("location", call, "wifiTrackPermCallback") return } doStartWifiTrack(call, bssid, intervalMs) } @PermissionCallback private fun wifiTrackPermCallback(call: PluginCall) { if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) doStartWifiTrack(call, bssid, intervalMs) } else { call.reject("Standortberechtigung für WLAN-Tracker abgelehnt") } } private fun doStartWifiTrack(call: PluginCall, bssid: String, intervalMs: Int) { val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val connectedBssid = wifi.connectionInfo?.bssid val mode = if (bssid.equals(connectedBssid, ignoreCase = true)) "connected" else "scan" val runId = "wifi-${System.currentTimeMillis()}" val run = WifiTrackRun(bssid, mode, intervalMs) wifiTrackRuns[runId] = run if (mode == "connected") { // Live-Polling vom verbundenen AP — kein Scan-Throttling io.launch { try { while (run.active) { @Suppress("DEPRECATION") val ci = wifi.connectionInfo if (ci != null && ci.rssi != -127 && bssid.equals(ci.bssid, ignoreCase = true)) { val now = System.currentTimeMillis() val ev = JSObject() .put("runId", runId) .put("ts", now) .put("rssi", ci.rssi) .put("source", "connected") run.samples.add(ev) notifyListeners("wifiSignal", ev) } Thread.sleep(intervalMs.toLong()) } } catch (_: Exception) { /* Tracker endet */ } } } else { // Scan-Modus: BroadcastReceiver + periodisches startScan val receiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context?, intent: Intent?) { if (intent?.action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return try { val match = wifi.scanResults.firstOrNull { it.BSSID.equals(bssid, ignoreCase = true) } ?: return val now = System.currentTimeMillis() val ev = JSObject() .put("runId", runId) .put("ts", now) .put("rssi", match.level) .put("source", "scan") run.samples.add(ev) notifyListeners("wifiSignal", ev) } catch (_: Exception) { /* ignorieren */ } } } run.scanReceiver = receiver context.applicationContext.registerReceiver( receiver, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION), ) // Erster Scan sofort, dann alle 30 s neu triggern io.launch { try { @Suppress("DEPRECATION") wifi.startScan() while (run.active) { Thread.sleep(30_000L) if (!run.active) break @Suppress("DEPRECATION") wifi.startScan() } } catch (_: Exception) { /* Tracker endet */ } } } resolve(call, JSObject().put("runId", runId).put("mode", mode)) } @PluginMethod fun stopWifiTrack(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = wifiTrackRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") run.active = false run.scanReceiver?.let { try { context.applicationContext.unregisterReceiver(it) } catch (_: Exception) { /* schon abgemeldet */ } } val samples = JSArray() var min = Int.MAX_VALUE var max = Int.MIN_VALUE var sum = 0L var n = 0 run.samples.forEach { s -> samples.put(JSObject() .put("ts", s.optLong("ts")) .put("rssi", s.optInt("rssi")) .put("source", s.optString("source"))) val r = s.optInt("rssi") if (r < min) min = r if (r > max) max = r sum += r n++ } val avg = if (n > 0) (sum / n).toInt() else 0 resolve(call, JSObject() .put("samples", samples) .put("min", if (n > 0) min else 0) .put("max", if (n > 0) max else 0) .put("avg", avg)) } /** Status eines Tracker-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ @PluginMethod fun getWifiTrackStatus(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = wifiTrackRuns[runId] val samples = JSArray() run?.samples?.forEach { s -> samples.put(JSObject() .put("ts", s.optLong("ts")) .put("rssi", s.optInt("rssi")) .put("source", s.optString("source"))) } resolve(call, JSObject() .put("running", run != null && run.active) .put("samples", samples) .put("mode", run?.mode ?: "connected")) } /* --------------------------------------------------------------------- */ /* App-Update: APK herunterladen und Paketinstaller öffnen */ /* --------------------------------------------------------------------- */ /** * Lädt die neue APK vom (authentifizierten) Update-Proxy des netdiag-Moduls * herunter und öffnet den Android-Paketinstaller. Der Download-Fortschritt * wird laufend als `updateProgress`-Event (0–100 %) gemeldet. * * Vor Android 8 genügt die globale Einstellung „Unbekannte Quellen". Ab * Android 8 muss die App einzeln berechtigt sein — fehlt das Recht, wird * der passende Einstellungs-Dialog geöffnet und der Aufruf abgewiesen. */ @PluginMethod fun installUpdate(call: PluginCall) { val url = call.getString("url") ?: return call.reject("url fehlt") io.launch { try { // Ab Android 8: App braucht das Recht, Pakete zu installieren if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls() ) { val perm = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) .setData(Uri.parse("package:${context.packageName}")) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(perm) call.reject("Bitte erlauben, dass NetDiag Apps installieren darf, dann erneut tippen") return@launch } val apk = File(context.cacheDir, "NetDiag-update.apk") downloadApk(url, apk) val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", apk ) val install = Intent(Intent.ACTION_VIEW) .setDataAndType(uri, "application/vnd.android.package-archive") .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(install) resolve(call, JSObject().put("started", true)) } catch (e: Exception) { call.reject("installUpdate: ${e.message}") } } } /** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */ private fun downloadApk(url: String, target: File) { val conn = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000 readTimeout = 120_000 instanceFollowRedirects = true } try { val code = conn.responseCode if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)") val total = conn.contentLength.toLong() // -1 wenn unbekannt var read = 0L var lastPct = -1 conn.inputStream.use { input -> FileOutputStream(target).use { out -> val buf = ByteArray(64 * 1024) while (true) { val n = input.read(buf) if (n < 0) break out.write(buf, 0, n) read += n if (total > 0) { val pct = (read * 100 / total).toInt() if (pct != lastPct) { lastPct = pct notifyListeners( "updateProgress", JSObject().put("percent", pct) ) } } } } } if (target.length() < 1024) throw Exception("APK unvollständig empfangen") } finally { conn.disconnect() } } /* --------------------------------------------------------------------- */ /* Hilfsfunktionen */ /* --------------------------------------------------------------------- */ private fun resolve(call: PluginCall, data: JSObject) { // Capacitor erwartet die Auflösung auf dem Main-Thread activity.runOnUiThread { call.resolve(data) } } private fun intToIp(i: Int): String = "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" private fun firstLocalIpv4(): String { java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> ni.inetAddresses.toList().forEach { addr -> if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { return addr.hostAddress ?: "" } } } return "192.168.1.1" } /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ private fun readArpTable(): Map { val map = HashMap() try { BufferedReader(FileReader(File("/proc/net/arp"))).use { br -> br.readLine() // Kopfzeile var line = br.readLine() while (line != null) { val parts = line.split(Regex("\\s+")) if (parts.size >= 4 && parts[3] != "00:00:00:00:00:00") { map[parts[0]] = parts[3].uppercase() } line = br.readLine() } } } catch (_: Exception) { } return map } /** ping mit fester TTL -> (antwortende IP, Latenz in ms) */ private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean) private fun pingWithTtl(host: String, ttl: Int): Hop { return try { val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host) .redirectErrorStream(true).start() val out = proc.inputStream.bufferedReader().readText() proc.waitFor() val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1) ?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1) ?: "*" val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 val reached = out.contains("bytes from") Hop(if (reached) host else ip, ms, reached) } catch (e: Exception) { Hop("*", 0.0, false) } } private fun freqToChannel(freq: Int): Int = when { freq == 2484 -> 14 freq in 2412..2472 -> (freq - 2412) / 5 + 1 freq in 5170..5825 -> (freq - 5170) / 5 + 34 else -> 0 } private fun serviceName(port: Int): String = when (port) { 21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http" 139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus" 1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt" else -> "" } /** Minimaler OUI-Hersteller-Lookup. Für vollständige Abdeckung OUI-DB einbinden. */ private fun ouiVendor(mac: String): String { val oui = mac.replace(":", "").take(6).uppercase() return OUI[oui] ?: "" } private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 companion object { /** * Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld * (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf * Vollständigkeit — unbekannte MACs liefern einfach einen leeren Vendor. */ private val OUI: Map = buildMap { // AVM / FRITZ!Box listOf("00040E", "3810D5", "5C4979", "C80E14", "E0286D", "3410F4") .forEach { put(it, "AVM") } // TP-Link listOf("003192", "50C7BF", "D8EB97", "EC086B", "A42BB0", "1CFA68", "14CC20", "B0487A", "6032B1", "5091E3").forEach { put(it, "TP-Link") } // Netgear listOf("000FB5", "20E52A", "00146C", "001B2F", "001E2A", "00223F", "0024B2", "28C68E", "A040A0", "3C3786", "6CB0CE").forEach { put(it, "Netgear") } // Ubiquiti listOf("00156D", "0418D6", "24A43C", "44D9E7", "687251", "788A20", "802AA8", "B4FB0E", "DC9FDB", "F09FC2", "FCECDA", "74ACB9", "18E829", "944A0C", "E063DA").forEach { put(it, "Ubiquiti") } // Cisco listOf("00000C", "001AA1", "0023AC", "F09E63").forEach { put(it, "Cisco") } // MikroTik listOf("000C42", "4C5E0C", "6C3B6B", "CC2DE0", "E48D8C", "64D154", "B869F4", "18FD74", "2CC81B", "DC2C6E", "744D28", "488F5A") .forEach { put(it, "MikroTik") } // D-Link listOf("001195", "1CBDB9", "001B11", "001CF0", "14D64D", "28107B", "78542E", "B8A386", "C8BE19").forEach { put(it, "D-Link") } // Hikvision (Kameras) listOf("4419B6", "BCAD28", "C05627", "2857BE", "4CBD8F", "54C415", "A41437", "B4A382", "18680F").forEach { put(it, "Hikvision") } // Dahua (Kameras) listOf("3CEF8C", "9002A9", "14A78B", "E0508B", "08EDED", "24526A", "6C1C71").forEach { put(it, "Dahua") } // Axis (Kameras) listOf("00408C", "ACCC8E", "B8A44F", "E82725").forEach { put(it, "Axis") } // Drucker listOf("001E0B", "3CD92B", "9457A5", "001321", "A0481C", "308D99", "380025", "00215A", "9C8E99", "EC8EB5", "705A0F", "B499BA") .forEach { put(it, "HP") } listOf("008077", "30055C", "001BA9").forEach { put(it, "Brother") } listOf("002673", "88873D", "F48139", "2C9EFC", "001E8F", "B08E1A") .forEach { put(it, "Canon") } listOf("000048", "0026AB", "A4EE57", "64EB8C", "44D244", "381A52") .forEach { put(it, "Epson") } // Apple listOf("F0B479", "3C0754", "A4B197", "DC2B2A", "040CCE", "7CD1C3", "F0DBF8", "88665A", "28CFE9", "001EC2", "002500", "D8A25E") .forEach { put(it, "Apple") } // Samsung listOf("002566", "8425DB", "5CF6DC", "0017C9", "001A8A", "3423BA", "781FDB", "8C7712", "BC1485", "5C0A5B").forEach { put(it, "Samsung") } // Espressif (ESP32/ESP8266 — Shelly, Tasmota, viele IoT-Geräte) listOf("240AC4", "30AEA4", "246F28", "84CCA8", "A020A6", "7C9EBD", "8CAAB5", "3C6105", "24B2DE", "DC4F22", "84F3EB", "BCDDC2", "A4CF12", "CC50E3", "2462AB", "18FE34", "5CCF7F", "600194", "2C3AE8", "ECFABC", "B4E62D", "9038C9").forEach { put(it, "Espressif") } // Raspberry Pi listOf("B827EB", "DCA632", "E45F01", "28CDC1", "D83ADD", "2CCF67") .forEach { put(it, "Raspberry Pi") } // Intel listOf("001CC0", "3CA9F4", "A0A8CD", "8C1645", "7CB27D", "9C305B", "0013E8", "5C514F", "94659C").forEach { put(it, "Intel") } // Sonos listOf("000E58", "5CAAFD", "949F3E", "B8E937", "347E5C", "48A6B8", "542A1B").forEach { put(it, "Sonos") } // NAS listOf("001132", "9009D0").forEach { put(it, "Synology") } listOf("00089B", "245EBE").forEach { put(it, "QNAP") } // Amazon (Echo / Fire) listOf("8871E5", "FCA183", "44650D", "F0272D", "68DBF5", "50DCE7", "AC63BE", "40B4CD", "0C47C9", "74C246").forEach { put(it, "Amazon") } // Google / Nest / Chromecast listOf("F4F5D8", "F4F5E8", "30FD38", "6CADF8", "546009", "A47733", "1CF29A", "3C5AB4", "D86C63", "48D6D5").forEach { put(it, "Google") } // Industrie / Gebäudetechnik listOf("000E8C", "001B1B", "286336", "001C06", "8CF319") .forEach { put(it, "Siemens") } put("00A057", "Lancom") put("001A22", "eQ-3 / Homematic") } } }