feat(satellite): SNMP-Anreicherung bei Discovery — Switches/Router/APs/NAS geben Infos preis

SNMP soll nicht nur Drucker abfragen: jedes beim Scan entdeckte Geraet mit IP
wird jetzt kurz nach seiner SNMP-System-Group (RFC 1213) gefragt. Antwortet es,
haengt der Satellit ein 'snmp'-Feld an (sysName/sysDescr/sysContact/sysLocation/
sysUpTime) und leitet einen praeziseren 'type' aus sysDescr ab (switch/router/
access-point/nas/printer/ups). Aus einer nackten ARP-IP wird so ein benanntes,
klassifiziertes Geraet im Inventar, das ARIA via satellite_devices sieht.

- parallel (Semaphore, Default 16) mit kurzem Timeout (-t1 -r0, 2s) -> Nicht-
  SNMP-Hosts fallen sofort raus, der Scan bleibt flott.
- SNMP_DISCOVERY (Default true) schaltet es ab; Concurrency/Timeout per env.
- satellite_devices-Tool: ARIA weiss jetzt vom snmp-Feld + genaueren type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 17:03:50 +02:00
co-authored by Claude Opus 4.8
parent 02303c35ee
commit 8b1555f926
3 changed files with 97 additions and 5 deletions
+5 -2
View File
@@ -1199,8 +1199,11 @@ META_TOOLS = [
"name": "satellite_devices",
"description": (
"Listet die Geraete im Netz eines Satelliten (Fire TV, Chromecast, "
"Smart-TVs, Drucker, NAS, Hosts ...). Nutze es um herauszufinden welches "
"Geraet gemeint ist, BEVOR Du satellite_command aufrufst."
"Smart-TVs, Drucker, Switches, Router, APs, NAS, Hosts ...). Nutze es um "
"herauszufinden welches Geraet gemeint ist, BEVOR Du satellite_command "
"aufrufst. SNMP-faehige Geraete tragen ein 'snmp'-Feld (Name, Beschreibung, "
"Standort, Uptime) und einen praeziseren 'type' (switch/router/nas ...) — "
"gute Quelle um Netz-Hardware zu erklaeren."
),
"parameters": {
"type": "object",
+6 -1
View File
@@ -33,9 +33,14 @@ CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer
# ─── SNMP (optional) ───────────────────────────────────────────────
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
SNMP_COMMUNITY=public # Drucker antworten meist auf 'public'
SNMP_COMMUNITY=public # Drucker/Switches antworten meist auf 'public'
SNMP_VERSION=2c # 1 | 2c
SNMP_TIMEOUT_SEC=5
# Discovery-Anreicherung: jedes entdeckte Geraet wird beim Scan kurz per SNMP
# nach Name/Beschreibung/Standort/Uptime gefragt (Switches, Router, APs, NAS ...).
SNMP_DISCOVERY=true
SNMP_DISCOVERY_CONCURRENCY=16 # parallele SNMP-Abfragen pro Scan
SNMP_DISCOVERY_TIMEOUT=2 # Timeout je Geraet (s) — Nicht-SNMP-Hosts fallen schnell raus
# ─── HTTP (optional) ───────────────────────────────────────────────
HTTP_TIMEOUT_SEC=10 # Timeout fuer http.get/http.post
+86 -2
View File
@@ -139,6 +139,23 @@ SNMP_SUPPLY_DESC = "1.3.6.1.2.1.43.11.1.1.6.1" # Beschreibung (z.B. "Black Ink
SNMP_SUPPLY_MAX = "1.3.6.1.2.1.43.11.1.1.8.1" # Max-Kapazitaet
SNMP_SUPPLY_LVL = "1.3.6.1.2.1.43.11.1.1.9.1" # aktueller Fuellstand
# SNMP-Anreicherung bei der Discovery: jedes entdeckte Geraet mit IP wird kurz
# nach seiner System-Group (RFC 1213) gefragt. Switches/Router/APs/NAS geben so
# Name, Beschreibung, Standort & Uptime preis -> im Inventar (satellite_devices)
# sichtbar. Abschaltbar; kurzer Timeout + parallel, damit der Scan flott bleibt.
SNMP_DISCOVERY = _env_bool("SNMP_DISCOVERY", True)
SNMP_DISCOVERY_CONCURRENCY = int(os.environ.get("SNMP_DISCOVERY_CONCURRENCY", "16") or "16")
SNMP_DISCOVERY_TIMEOUT = float(os.environ.get("SNMP_DISCOVERY_TIMEOUT", "2") or "2")
# System-Group (RFC 1213) .0-Instanzen:
SNMP_SYS_OIDS = {
"descr": "1.3.6.1.2.1.1.1.0", # sysDescr
"objectid": "1.3.6.1.2.1.1.2.0", # sysObjectID
"uptime": "1.3.6.1.2.1.1.3.0", # sysUpTime
"contact": "1.3.6.1.2.1.1.4.0", # sysContact
"name": "1.3.6.1.2.1.1.5.0", # sysName
"location": "1.3.6.1.2.1.1.6.0", # sysLocation
}
HEARTBEAT_SEC = 25
# mDNS-Servicetypen, die fuer ARIA interessant sind.
@@ -638,6 +655,44 @@ def _to_int(s: str):
return None
def _snmp_system(ip: str, community: str = "", version: str = "") -> Optional[dict]:
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
Kurzer Timeout, keine Retries -> Nicht-SNMP-Hosts scheitern schnell."""
base = ["-v", str(version or SNMP_VERSION), "-c", str(community or SNMP_COMMUNITY),
"-t", "1", "-r", "0"]
keys = list(SNMP_SYS_OIDS.keys())
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
SNMP_DISCOVERY_TIMEOUT)
if not ok:
return None
vals = out.splitlines()
info = {}
for k, v in zip(keys, vals):
v = (v or "").strip().strip('"')
if v and "No Such" not in v and "No more" not in v:
info[k] = v
return info or None
def _snmp_kind(descr: str) -> str:
"""Grobe Geraeteklasse aus sysDescr (fuer type im Inventar)."""
d = (descr or "").lower()
if any(k in d for k in ("switch", "catalyst", "procurve", "aruba", "powerconnect")):
return "switch"
if any(k in d for k in ("router", "mikrotik", "routeros", "edgeos", "openwrt", "pfsense", "fritz!box")):
return "router"
if any(k in d for k in ("access point", "accesspoint", "unifi", "wifi", "wlan")):
return "access-point"
if any(k in d for k in ("printer", "laserjet", "officejet", "brother", "epson", "kyocera")):
return "printer"
if any(k in d for k in ("nas", "synology", "qnap", "truenas", "diskstation")):
return "nas"
if any(k in d for k in ("ups", "usv", "smart-ups")):
return "ups"
return ""
# ─── Helpers ────────────────────────────────────────────────────────
def _slug(s: str) -> str:
@@ -687,13 +742,42 @@ class Satellite:
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
arp = await loop.run_in_executor(None, _discover_arp)
self._devices = _merge_devices(mdns, ssdp, arp)
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
self._devices_ts = time.time()
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp))
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp)
finally:
self._scanning = False
return self._devices
async def _enrich_snmp(self, devices: list) -> None:
"""Fragt jedes Geraet mit IP parallel per SNMP-System-Group ab und haengt
die Infos an. Verbessert Name/Typ, wenn bisher nur eine IP bekannt war."""
loop = asyncio.get_event_loop()
sem = asyncio.Semaphore(SNMP_DISCOVERY_CONCURRENCY)
async def _one(dev: dict) -> None:
ip = dev.get("ip") or ""
if not ip:
return
async with sem:
info = await loop.run_in_executor(None, _snmp_system, ip)
if not info:
return
dev["snmp"] = info
dev["snmpCapable"] = True
# Name aufwerten, wenn er bisher nur die IP/leer war.
if info.get("name") and dev.get("name", "") in ("", ip):
dev["name"] = info["name"]
# Typ aufwerten, wenn bisher generisch (host/leer).
kind = _snmp_kind(info.get("descr", ""))
if kind and dev.get("type", "") in ("", "host"):
dev["type"] = kind
await asyncio.gather(*(_one(d) for d in devices))
async def _send(self, message: dict) -> None:
if self.ws is None:
return