Some checks failed
Build APK / build-apk (push) Failing after 11m29s
SvelteKit + Capacitor 6 Netzwerk-Diagnose-App: - Tool-Plattform (IP-Scan, Port, Ping, WLAN, DHCP, SNMP, Traceroute, Stresstest, iperf) - Offline-First SQLite-Cache + idempotenter Dolibarr-Sync - Natives Kotlin-Plugin NetDiagScanner (ARP, Ping, Ports, WLAN, DHCP, SNMP, Traceroute) - Backbutton-Single-Instance-Modul, Auto-Updater, Toast-System - Auftrags-/Kunden-Übersicht nach Baustellen-App-Muster - CI: [apk]-Tag → Forgejo Runner → Package Registry netdiag-apk Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
551 lines
22 KiB
Kotlin
551 lines
22 KiB
Kotlin
package de.data_it_solution.netdiag
|
|
|
|
import android.Manifest
|
|
import android.content.Context
|
|
import android.net.wifi.WifiManager
|
|
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.FileReader
|
|
import java.net.DatagramPacket
|
|
import java.net.DatagramSocket
|
|
import java.net.InetAddress
|
|
import java.net.InetSocketAddress
|
|
import java.net.Socket
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
|
|
/**
|
|
* 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<String, StressRun>()
|
|
|
|
/* --------------------------------------------------------------------- */
|
|
/* Subnetz / lokale Netzwerkinfo */
|
|
/* --------------------------------------------------------------------- */
|
|
|
|
@PluginMethod
|
|
fun getLocalSubnet(call: PluginCall) {
|
|
io.launch {
|
|
try {
|
|
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
|
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
|
val ipInt = dhcp?.ipAddress ?: 0
|
|
val gwInt = dhcp?.gateway ?: 0
|
|
val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4()
|
|
val gateway = if (gwInt != 0) intToIp(gwInt) else ""
|
|
val base = ip.substringBeforeLast('.', "192.168.1")
|
|
resolve(call, JSObject()
|
|
.put("subnet", "$base.0/24")
|
|
.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 base = subnet.substringBeforeLast('.', "192.168.1")
|
|
io.launch {
|
|
try {
|
|
// Parallel-Ping über das gesamte /24
|
|
val alive = withContext(Dispatchers.IO) {
|
|
(1..254).map { host ->
|
|
async {
|
|
val ip = "$base.$host"
|
|
if (InetAddress.getByName(ip).isReachable(350)) ip else null
|
|
}
|
|
}.awaitAll().filterNotNull()
|
|
}
|
|
val arp = readArpTable()
|
|
val devices = JSArray()
|
|
for (ip in alive) {
|
|
val dev = JSObject().put("ip", ip)
|
|
arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) }
|
|
try {
|
|
val name = InetAddress.getByName(ip).canonicalHostName
|
|
if (name != ip) dev.put("hostname", name)
|
|
} catch (_: Exception) { }
|
|
devices.put(dev)
|
|
}
|
|
resolve(call, JSObject().put("devices", devices))
|
|
} catch (e: Exception) {
|
|
call.reject("ipScan: ${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<Double>()
|
|
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-Discover (Rogue-DHCP-Erkennung) */
|
|
/* --------------------------------------------------------------------- */
|
|
|
|
@PluginMethod
|
|
fun dhcpDiscover(call: PluginCall) {
|
|
io.launch {
|
|
try {
|
|
val servers = discoverDhcpServers()
|
|
val arr = JSArray()
|
|
val arp = readArpTable()
|
|
for (ip in servers) {
|
|
arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: ""))
|
|
}
|
|
resolve(call, JSObject().put("servers", arr))
|
|
} catch (e: Exception) {
|
|
call.reject("dhcpDiscover: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server.
|
|
* Best-effort: scheitert auf manchen Geräten an Port-68-Belegung.
|
|
*/
|
|
private fun discoverDhcpServers(): List<String> {
|
|
val found = LinkedHashSet<String>()
|
|
val socket = DatagramSocket()
|
|
socket.broadcast = true
|
|
socket.soTimeout = 3000
|
|
try {
|
|
val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12)
|
|
val packet = buildDhcpDiscover(xid)
|
|
socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67))
|
|
val buf = ByteArray(1500)
|
|
val deadline = System.currentTimeMillis() + 3000
|
|
while (System.currentTimeMillis() < deadline) {
|
|
try {
|
|
val resp = DatagramPacket(buf, buf.size)
|
|
socket.receive(resp)
|
|
// Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender
|
|
val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress
|
|
if (srv != null) found.add(srv)
|
|
} catch (_: Exception) {
|
|
break
|
|
}
|
|
}
|
|
} finally {
|
|
socket.close()
|
|
}
|
|
return found.toList()
|
|
}
|
|
|
|
/* --------------------------------------------------------------------- */
|
|
/* 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()
|
|
for (ttl in 1..20) {
|
|
val hop = pingWithTtl(host, ttl)
|
|
hops.put(JSObject()
|
|
.put("ttl", ttl)
|
|
.put("ip", hop.first)
|
|
.put("ms", hop.second))
|
|
if (hop.first == host || hop.reachedTarget) 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)
|
|
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
|
|
}
|
|
|
|
/* --------------------------------------------------------------------- */
|
|
/* 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<String, String> {
|
|
val map = HashMap<String, String>()
|
|
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
|
|
|
|
private fun buildDhcpDiscover(xid: ByteArray): ByteArray {
|
|
val p = ByteArray(300)
|
|
p[0] = 1 // op = BOOTREQUEST
|
|
p[1] = 1 // htype = Ethernet
|
|
p[2] = 6 // hlen
|
|
System.arraycopy(xid, 0, p, 4, 4)
|
|
p[10] = 0x80.toByte() // Broadcast-Flag
|
|
// Magic Cookie
|
|
p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99
|
|
// Option 53: DHCP Message Type = DISCOVER
|
|
p[240] = 53; p[241] = 1; p[242] = 1
|
|
p[243] = 255.toByte() // Ende
|
|
return p
|
|
}
|
|
|
|
private fun parseDhcpServerId(buf: ByteArray, len: Int): String? {
|
|
var i = 240
|
|
while (i + 1 < len) {
|
|
val opt = buf[i].toInt() and 0xFF
|
|
if (opt == 255) break
|
|
if (opt == 0) { i++; continue }
|
|
val l = buf[i + 1].toInt() and 0xFF
|
|
if (opt == 54 && l == 4) {
|
|
return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." +
|
|
"${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}"
|
|
}
|
|
i += 2 + l
|
|
}
|
|
return null
|
|
}
|
|
|
|
companion object {
|
|
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
|
|
private val OUI = mapOf(
|
|
"3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi",
|
|
"001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link"
|
|
)
|
|
}
|
|
}
|