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>
1556 lines
68 KiB
Kotlin
1556 lines
68 KiB
Kotlin
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<String, StressRun>()
|
||
|
||
/* --------------------------------------------------------------------- */
|
||
/* 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<Int>,
|
||
)
|
||
|
||
/**
|
||
* 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<Int> {
|
||
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<Int>): 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<String> = ConcurrentHashMap.newKeySet()
|
||
}
|
||
|
||
@Suppress("DEPRECATION")
|
||
private fun discoverMdns(timeoutMs: Long): Map<String, MdnsInfo> {
|
||
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<String, MdnsInfo>()
|
||
val pending = ConcurrentLinkedQueue<NsdServiceInfo>()
|
||
val listeners = ArrayList<NsdManager.DiscoveryListener>()
|
||
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<Int> {
|
||
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<Int>()
|
||
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<String, MutableSet<String>>()
|
||
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<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-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<String, MonitorRun>()
|
||
|
||
private class MonitorRun(
|
||
val targets: List<Pair<String, String>>,
|
||
val intervalSec: Int,
|
||
) {
|
||
@Volatile var active = true
|
||
/** je IP: true = erreichbar */
|
||
val state = ConcurrentHashMap<String, Boolean>()
|
||
/** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */
|
||
val downSince = ConcurrentHashMap<String, Long>()
|
||
/** alle bisher erzeugten Ereignisse — für die UI-Wiederaufnahme */
|
||
val events = java.util.concurrent.CopyOnWriteArrayList<JSObject>()
|
||
}
|
||
|
||
/**
|
||
* Ü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<Pair<String, String>>()
|
||
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/<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 */
|
||
/* --------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 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<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
|
||
|
||
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<String, String> = 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")
|
||
}
|
||
}
|
||
}
|