diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 9315c31..a83f006 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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", diff --git a/satellite/.env.example b/satellite/.env.example index c248591..412f2d0 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -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 diff --git a/satellite/satellite.py b/satellite/satellite.py index 0e67f30..91ef6d9 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -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