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:
Eduard Wisch 2026-05-19 22:42:25 +02:00
parent 2a75ad96b2
commit 1a0f1dc5ca
9 changed files with 925 additions and 87 deletions

View file

@ -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" />

View file

@ -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")
}
}
}

View file

@ -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")
}
}
}

View 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>

View file

@ -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() };

View file

@ -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'] },
],
};
},

View file

@ -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,
};
},
};

View file

@ -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 */

View file

@ -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>