IP-Scan: Geräte deutlich umfassender erkennen
- NetBIOS-Namensabfrage (UDP 137) je Host integriert - mdnsScan: neue Plugin-Methode, mDNS/Bonjour via NsdManager — findet Drucker, Kameras, Chromecast, AirPlay; liefert Namen + Diensttypen - Quick-Port-Probe (22/80/443/554/9100 …) speist eine deviceType-Heuristik (Kamera, Drucker, Router, Switch, NAS, Wallbox, Server …) - OUI-Vendor-Tabelle von 6 auf ~150 kuratierte Einträge erweitert - ipscan.ts führt IP-Scan + mDNS pro IP zusammen, mDNS-only-Geräte ergänzt - neue DeviceCard-Komponente: zeigt Geräteart-Badge, offene Ports, mDNS-Dienste, mac, NetBIOS-Name; ersetzt die Inline-Geräteliste - upsertDevice überschreibt vorhandene Daten nicht mehr mit undefined (Favorit/eigener Name bleiben bei magerem Re-Scan erhalten) - Manifest: CHANGE_WIFI_MULTICAST_STATE für die mDNS-Suche Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2a75ad96b2
commit
1a0f1dc5ca
9 changed files with 925 additions and 87 deletions
|
|
@ -41,6 +41,8 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<!-- Multicast-Lock für die mDNS-/Bonjour-Dienstsuche (NsdManager) -->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
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
|
||||
|
|
@ -27,6 +29,8 @@ 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
|
||||
|
|
@ -34,6 +38,9 @@ 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.
|
||||
|
|
@ -150,14 +157,36 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}.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 (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) { }
|
||||
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))
|
||||
|
|
@ -167,6 +196,215 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
|
|
@ -692,10 +930,86 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
|
||||
|
||||
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"
|
||||
)
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
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
|
||||
|
|
@ -27,6 +29,8 @@ 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
|
||||
|
|
@ -34,6 +38,9 @@ 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.
|
||||
|
|
@ -150,14 +157,36 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}.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 (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) { }
|
||||
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))
|
||||
|
|
@ -167,6 +196,215 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
|
|
@ -692,10 +930,86 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
|
||||
|
||||
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"
|
||||
)
|
||||
/**
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
128
src/lib/components/DeviceCard.svelte
Normal file
128
src/lib/components/DeviceCard.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Gerätekarte — zeigt ein gefundenes Netzwerkgerät mit allen ermittelten
|
||||
* Bezeichnungen (Hersteller, Geräteart, mDNS-/NetBIOS-Name, offene Ports),
|
||||
* seinen Messungen und den verfügbaren Geräte-Werkzeugen.
|
||||
*
|
||||
* Wird auf der Protokoll-Detailseite, der Geräte-/Favoritenseite und in
|
||||
* gespeicherten Scans wiederverwendet. Stern- und Umbenennen-Steuerung
|
||||
* erscheinen nur, wenn die jeweiligen Callbacks gesetzt sind.
|
||||
*/
|
||||
import { Star, Pencil } from 'lucide-svelte';
|
||||
import MeasurementResult from './MeasurementResult.svelte';
|
||||
import type { Device, Measurement } from '$lib/types';
|
||||
import type { Tool } from '$lib/tools/types';
|
||||
|
||||
let {
|
||||
device,
|
||||
measurements = [],
|
||||
tools = [],
|
||||
onrun,
|
||||
onfavorite,
|
||||
onrename,
|
||||
}: {
|
||||
device: Device;
|
||||
measurements?: Measurement[];
|
||||
tools?: Tool[];
|
||||
onrun?: (tool: Tool) => void;
|
||||
onfavorite?: () => void;
|
||||
onrename?: () => void;
|
||||
} = $props();
|
||||
|
||||
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
|
||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
|
||||
|
||||
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
|
||||
const title = $derived(
|
||||
device.customName ||
|
||||
device.mdnsName ||
|
||||
device.hostname ||
|
||||
device.netbiosName ||
|
||||
device.ip,
|
||||
);
|
||||
/** Zweitzeile mit Bezeichnern, die nicht schon im Titel stehen */
|
||||
const detail = $derived(
|
||||
[
|
||||
device.ip !== title ? device.ip : '',
|
||||
device.netbiosName && device.netbiosName !== title ? 'NB: ' + device.netbiosName : '',
|
||||
device.mac,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate font-medium">{title}</span>
|
||||
{#if onrename}
|
||||
<button
|
||||
class="shrink-0 text-zinc-500 active:text-zinc-300"
|
||||
onclick={onrename}
|
||||
aria-label="Gerät umbenennen"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if detail}<div class="truncate text-xs text-zinc-500">{detail}</div>{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if device.vendor}<span class="text-xs text-zinc-500">{device.vendor}</span>{/if}
|
||||
{#if onfavorite}
|
||||
<button
|
||||
class="active:scale-90 {device.isFavorite ? 'text-amber-400' : 'text-zinc-600'}"
|
||||
onclick={onfavorite}
|
||||
aria-label="Als Favorit markieren"
|
||||
>
|
||||
<Star size={18} fill={device.isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length}
|
||||
<div class="mt-1.5 flex flex-wrap gap-1">
|
||||
{#if device.deviceType}
|
||||
<span class="rounded bg-sky-900/60 px-1.5 py-0.5 text-[10px] text-sky-300">
|
||||
{device.deviceType}
|
||||
</span>
|
||||
{/if}
|
||||
{#each device.openPorts ?? [] as port (port)}
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-400">:{port}</span>
|
||||
{/each}
|
||||
{#each device.mdnsServices ?? [] as svc (svc)}
|
||||
<span class="rounded bg-zinc-800 px-1.5 py-0.5 text-[10px] text-zinc-500">{svc}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if device.note}
|
||||
<p class="mt-1 text-xs text-zinc-400">{device.note}</p>
|
||||
{/if}
|
||||
|
||||
{#each measurements as m (m.clientId)}
|
||||
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
|
||||
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
||||
<MeasurementResult result={m.result} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if tools.length && onrun}
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each tools as tool (tool.id)}
|
||||
<button
|
||||
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
||||
onclick={() => onrun?.(tool)}
|
||||
>
|
||||
{tool.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -44,11 +44,16 @@ export async function createProtocol(init: {
|
|||
/** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */
|
||||
export function upsertDevice(
|
||||
protocol: Protocol,
|
||||
dev: Omit<Device, 'clientId'> & { clientId?: string },
|
||||
dev: Partial<Device> & { ip: string },
|
||||
): Device {
|
||||
const existing = protocol.devices.find((d) => d.ip === dev.ip);
|
||||
if (existing) {
|
||||
Object.assign(existing, { ...dev, clientId: existing.clientId });
|
||||
// Nur gesetzte Felder übernehmen — ein magerer Re-Scan darf zuvor
|
||||
// gefundene Daten (mDNS-Name, Favorit, eigener Name) nicht überschreiben.
|
||||
const target = existing as unknown as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(dev)) {
|
||||
if (k !== 'clientId' && v !== undefined) target[k] = v;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const created: Device = { ...dev, clientId: dev.clientId ?? uid() };
|
||||
|
|
|
|||
|
|
@ -15,6 +15,20 @@ export interface ScannedDevice {
|
|||
mac?: string;
|
||||
hostname?: string;
|
||||
vendor?: string;
|
||||
/** geschätzte Geräteart (Kamera, Drucker, Router …) */
|
||||
deviceType?: string;
|
||||
/** NetBIOS-Name (UDP-137-Abfrage) */
|
||||
netbiosName?: string;
|
||||
/** offene Ports aus der Quick-Port-Probe */
|
||||
openPorts?: number[];
|
||||
}
|
||||
/** Ein per mDNS/Bonjour gefundenes Gerät */
|
||||
export interface MdnsDevice {
|
||||
ip: string;
|
||||
/** Bonjour-Anzeigename */
|
||||
name: string;
|
||||
/** angebotene Diensttypen, z.B. ['_googlecast._tcp', '_printer._tcp'] */
|
||||
services: string[];
|
||||
}
|
||||
export interface OpenPort {
|
||||
port: number;
|
||||
|
|
@ -62,6 +76,8 @@ export interface NetDiagScannerPlugin {
|
|||
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
|
||||
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
|
||||
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
|
||||
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
|
||||
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
|
||||
/** Port-Scan eines Geräts */
|
||||
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
|
||||
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
|
||||
|
|
@ -106,10 +122,56 @@ const mock: NetDiagScannerPlugin = {
|
|||
async ipScan() {
|
||||
return {
|
||||
devices: [
|
||||
{ ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01', hostname: 'fritzbox', vendor: 'AVM' },
|
||||
{ ip: '192.168.1.10', mac: 'AA:BB:CC:00:00:0A', hostname: 'switch-keller', vendor: 'TP-Link' },
|
||||
{ ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' },
|
||||
{ ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' },
|
||||
{
|
||||
ip: '192.168.1.1',
|
||||
mac: 'AA:BB:CC:00:00:01',
|
||||
hostname: 'fritzbox',
|
||||
vendor: 'AVM',
|
||||
deviceType: 'Router',
|
||||
openPorts: [53, 80, 443],
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.10',
|
||||
mac: 'AA:BB:CC:00:00:0A',
|
||||
hostname: 'switch-keller',
|
||||
vendor: 'TP-Link',
|
||||
deviceType: 'Switch',
|
||||
openPorts: [80],
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.40',
|
||||
mac: 'AA:BB:CC:00:00:28',
|
||||
hostname: 'ipcam-hof',
|
||||
vendor: 'Hikvision',
|
||||
deviceType: 'Kamera',
|
||||
openPorts: [80, 554],
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.50',
|
||||
mac: 'AA:BB:CC:00:00:32',
|
||||
hostname: 'handy',
|
||||
vendor: 'Samsung',
|
||||
deviceType: '',
|
||||
openPorts: [],
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.77',
|
||||
mac: 'AA:BB:CC:00:00:4D',
|
||||
hostname: 'wallbox',
|
||||
vendor: '',
|
||||
netbiosName: 'WALLBOX',
|
||||
deviceType: 'Wallbox',
|
||||
openPorts: [80, 502],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async mdnsScan() {
|
||||
return {
|
||||
devices: [
|
||||
{ ip: '192.168.1.20', name: 'Brother HL-L2350DW', services: ['_printer._tcp', '_ipp._tcp'] },
|
||||
{ ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] },
|
||||
{ ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,24 @@
|
|||
* dessen Subnetz direkt.
|
||||
*/
|
||||
|
||||
import { scanner } from '../../scanner';
|
||||
import { scanner, type MdnsDevice } from '../../scanner';
|
||||
import { debugLog } from '../../debuglog.svelte';
|
||||
import type { Device } from '../../types';
|
||||
import type { Tool } from '../types';
|
||||
|
||||
/** Geräteart aus den angebotenen mDNS-Diensten ableiten */
|
||||
function typeFromMdns(services: string[]): string {
|
||||
const s = services.join(' ');
|
||||
if (s.includes('_printer') || s.includes('_ipp') || s.includes('_pdl-datastream'))
|
||||
return 'Drucker';
|
||||
if (s.includes('_googlecast')) return 'Chromecast/TV';
|
||||
if (s.includes('_airplay') || s.includes('_raop')) return 'AirPlay-Gerät';
|
||||
if (s.includes('_rtsp') || s.includes('_axis-video')) return 'Kamera';
|
||||
if (s.includes('_hap')) return 'HomeKit-Gerät';
|
||||
if (s.includes('_smb')) return 'NAS';
|
||||
return '';
|
||||
}
|
||||
|
||||
export const ipScanTool: Tool = {
|
||||
id: 'ipscan',
|
||||
category: 'netzwerk',
|
||||
|
|
@ -66,18 +80,51 @@ export const ipScanTool: Tool = {
|
|||
`gescannt wird "${subnet}" (Quelle: ${source})`,
|
||||
);
|
||||
const { devices } = await scanner.ipScan({ subnet });
|
||||
debugLog.add('info', `IP-Scan Ergebnis: ${devices.length} Geräte in ${subnet}`);
|
||||
|
||||
// mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet
|
||||
// Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort.
|
||||
let mdns: MdnsDevice[] = [];
|
||||
try {
|
||||
mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices;
|
||||
} catch {
|
||||
/* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */
|
||||
}
|
||||
const mdnsByIp = new Map(mdns.map((m) => [m.ip, m]));
|
||||
|
||||
// Beide Quellen per IP zusammenführen
|
||||
const merged: (Partial<Device> & { ip: string })[] = devices.map((d) => {
|
||||
const m = mdnsByIp.get(d.ip);
|
||||
if (!m) return d;
|
||||
return {
|
||||
...d,
|
||||
mdnsName: m.name,
|
||||
mdnsServices: m.services,
|
||||
deviceType: d.deviceType || typeFromMdns(m.services),
|
||||
};
|
||||
});
|
||||
// Geräte, die nur per mDNS auftauchten, ergänzen
|
||||
for (const m of mdns) {
|
||||
if (merged.some((d) => d.ip === m.ip)) continue;
|
||||
merged.push({
|
||||
ip: m.ip,
|
||||
hostname: m.name,
|
||||
mdnsName: m.name,
|
||||
mdnsServices: m.services,
|
||||
deviceType: typeFromMdns(m.services),
|
||||
});
|
||||
}
|
||||
|
||||
debugLog.add(
|
||||
'info',
|
||||
`IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` +
|
||||
`(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`,
|
||||
);
|
||||
const via = source === 'adapter' ? ' (Adapter erkannt)' : '';
|
||||
return {
|
||||
label: `${devices.length} Geräte im Netz ${subnet}${via}`,
|
||||
result: { subnet, count: devices.length, quelle: source },
|
||||
measureStatus: devices.length > 0 ? 0 : 1,
|
||||
devices: devices.map((d) => ({
|
||||
ip: d.ip,
|
||||
mac: d.mac,
|
||||
hostname: d.hostname,
|
||||
vendor: d.vendor,
|
||||
})),
|
||||
label: `${merged.length} Geräte im Netz ${subnet}${via}`,
|
||||
result: { subnet, count: merged.length, quelle: source },
|
||||
measureStatus: merged.length > 0 ? 0 : 1,
|
||||
devices: merged,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,15 +43,9 @@ export interface ToolRunResult {
|
|||
measureStatus: MeasureStatus;
|
||||
/**
|
||||
* Optional: im Netzwerk gefundene Geräte. Werden vom Protokoll
|
||||
* übernommen (z.B. beim IP-Scan).
|
||||
* übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional.
|
||||
*/
|
||||
devices?: Array<{
|
||||
ip: string;
|
||||
mac?: string;
|
||||
hostname?: string;
|
||||
vendor?: string;
|
||||
deviceType?: string;
|
||||
}>;
|
||||
devices?: Array<Partial<Device> & { ip: string }>;
|
||||
}
|
||||
|
||||
/** Ein Diagnose-Werkzeug */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import ToolDialog from '$lib/components/ToolDialog.svelte';
|
||||
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
|
||||
import DeviceCard from '$lib/components/DeviceCard.svelte';
|
||||
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
|
||||
import { addMeasurement, upsertDevice } from '$lib/protocols';
|
||||
import { sync } from '$lib/sync.svelte';
|
||||
|
|
@ -65,16 +66,11 @@
|
|||
const tool = activeTool;
|
||||
const result = await tool.run({ params, protocol, device: activeDevice });
|
||||
|
||||
// Neu gefundene Geräte übernehmen (z.B. IP-Scan)
|
||||
// Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten
|
||||
// Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …)
|
||||
if (result.devices) {
|
||||
for (const d of result.devices) {
|
||||
upsertDevice(protocol, {
|
||||
ip: d.ip,
|
||||
mac: d.mac,
|
||||
hostname: d.hostname,
|
||||
vendor: d.vendor,
|
||||
deviceType: d.deviceType,
|
||||
});
|
||||
upsertDevice(protocol, { ...d, lastSeen: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,36 +203,12 @@
|
|||
</p>
|
||||
{/if}
|
||||
{#each protocol.devices as device (device.clientId)}
|
||||
<div class="mb-2 rounded-lg bg-zinc-900 p-3">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="font-medium">{device.ip}</span>
|
||||
<span class="text-xs text-zinc-500">{device.vendor ?? ''}</span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
{device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''}
|
||||
</div>
|
||||
|
||||
{#each measurementsFor(device.clientId) as m (m.clientId)}
|
||||
<div class="mt-1.5 flex items-start gap-2 border-t border-zinc-800 pt-1.5">
|
||||
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
|
||||
<MeasurementResult result={m.result} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each deviceTools as tool (tool.id)}
|
||||
<button
|
||||
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
||||
onclick={() => openTool(tool, device)}
|
||||
>
|
||||
{tool.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<DeviceCard
|
||||
{device}
|
||||
measurements={measurementsFor(device.clientId)}
|
||||
tools={deviceTools}
|
||||
onrun={(tool) => openTool(tool, device)}
|
||||
/>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue