diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 483dc10..9315c31 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1220,8 +1220,13 @@ META_TOOLS = [ "Beispiel YouTube-Video auf Fire TV: action='dial.launch', " "device='Fire TV', params={'app':'YouTube','v':''}. Weitere " "Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, " + "'snmp.printer' (params={'ip':''}) — BEVORZUGT fuer Drucker-" + "Tinte/Toner: liest die Fuellstaende zuverlaessig als Prozent aus der " + "Printer-MIB (kein HTML-Scrapen). 'snmp.get'/'snmp.walk' " + "(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige " + "SNMP-Werte. " "'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND " - "um Geraete-Statusseiten zu lesen (Drucker-Tinte, NAS ...). Bei grossen " + "um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen " "Seiten NICHT blind paginieren: setze params['contains'] (String oder " "Liste) — dann kommen nur Zeilen zurueck, die einen der Begriffe enthalten " "(z.B. contains=['ink','toner','cyan','magenta','yellow','black','%'] fuer " diff --git a/aria-brain/seed_rules.py b/aria-brain/seed_rules.py index 28ac25e..7611f41 100644 --- a/aria-brain/seed_rules.py +++ b/aria-brain/seed_rules.py @@ -492,14 +492,14 @@ SEED_RULES: List[dict] = [ " 3. `satellite_command(...)` — Aktion ausfuehren.\n" "\n" "Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im " - "Heimnetz online? -> satellite_command(satellite='', action='http.get', " - "params={'url':'http:///general/status.html', " - "'contains':['ink','toner','cyan','magenta','yellow','black','%']}) -> aus den " - "gefilterten Zeilen die Fuellstaende (BK/C/M/Y) vorlesen. Der contains-Filter " - "holt nur die relevanten Zeilen aus einer grossen Statusseite (sonst wird der " - "Body bei max_chars, Default 20000, abgeschnitten). Kommt nichts Brauchbares: " - "andere Statuspfade probieren (/general/information.html?kind=item, SNMP-UI) " - "oder offset erhoehen. NICHT aus dem Gedaechtnis raten.\n" + "Heimnetz online? -> BEVORZUGT satellite_command(satellite='', " + "action='snmp.printer', params={'ip':''}) -> liefert supplies mit " + "name + percent je Patrone (BK/C/M/Y), zuverlaessig aus der Printer-MIB. " + "NUR falls SNMP nichts liefert, als Fallback die HTML-Statusseite: " + "action='http.get', params={'url':'http:///general/status.html', " + "'contains':['ink','toner','cyan','magenta','yellow','black','%']} — der " + "contains-Filter zieht die relevanten Zeilen (sonst wird der Body bei " + "max_chars, Default 20000, abgeschnitten). NICHT aus dem Gedaechtnis raten.\n" "\n" "Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n" " satellite_command(satellite='Buero', device='Fire TV', " diff --git a/satellite/.env.example b/satellite/.env.example index 552ed96..c248591 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -24,9 +24,22 @@ CONTROL_ENABLED=true # Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt. # dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV) # wol Wake-on-LAN (Geraet per MAC aufwecken) -# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks) +# http.get generischer HTTP-GET (z.B. lokale IoT-Webhooks, Statusseiten) # http.post generischer HTTP-POST -CONTROL_ALLOWLIST=dial.launch,wol,http.get +# snmp.get einzelner SNMP-Wert (params: ip, oid) +# snmp.walk SNMP-Teilbaum (params: ip, oid) +# snmp.printer Drucker-Fuellstaende (Tinte/Toner) aus der Printer-MIB (params: ip) +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_VERSION=2c # 1 | 2c +SNMP_TIMEOUT_SEC=5 + +# ─── HTTP (optional) ─────────────────────────────────────────────── +HTTP_TIMEOUT_SEC=10 # Timeout fuer http.get/http.post +HTTP_MAX_CHARS=20000 # Default-Body-Ausschnitt (offset/max_chars pro Request) # ─── Discovery-Tuning (optional) ─────────────────────────────────── SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall diff --git a/satellite/Dockerfile b/satellite/Dockerfile index 9436edc..abc0d10 100644 --- a/satellite/Dockerfile +++ b/satellite/Dockerfile @@ -5,6 +5,12 @@ FROM python:3.12-slim WORKDIR /app +# net-snmp-CLI (snmpget/snmpwalk) fuer die snmp.*-Aktionen — z.B. Drucker- +# Tintenstaende zuverlaessig aus der Printer-MIB statt HTML zu scrapen. +RUN apt-get update \ + && apt-get install -y --no-install-recommends snmp \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/satellite/satellite.py b/satellite/satellite.py index 5c245da..0e67f30 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -113,7 +113,8 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False) CONTROL_ALLOWLIST = [ a.strip() for a in - os.environ.get("CONTROL_ALLOWLIST", "dial.launch,wol,http.get").split(",") + os.environ.get("CONTROL_ALLOWLIST", + "dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer").split(",") if a.strip() ] @@ -128,6 +129,16 @@ HTTP_TIMEOUT_SEC = float(os.environ.get("HTTP_TIMEOUT_SEC", "10") or "10") HTTP_MAX_CHARS = int(os.environ.get("HTTP_MAX_CHARS", "20000") or "20000") HTTP_MAX_CHARS_HARD = int(os.environ.get("HTTP_MAX_CHARS_HARD", "200000") or "200000") +# SNMP (net-snmp-CLI): Default-Community/Version + Timeout. Drucker antworten +# i.d.R. auf community 'public', v2c. +SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") or "public" +SNMP_VERSION = os.environ.get("SNMP_VERSION", "2c") or "2c" +SNMP_TIMEOUT_SEC = float(os.environ.get("SNMP_TIMEOUT_SEC", "5") or "5") +# Printer-MIB (RFC 3805) prtMarkerSuppliesEntry-Spalten (numerisch, ohne MIB-Files): +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 + HEARTBEAT_SEC = 25 # mDNS-Servicetypen, die fuer ARIA interessant sind. @@ -427,6 +438,10 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict: return await loop.run_in_executor(None, _do_wol, params) if action in ("http.get", "http.post"): return await loop.run_in_executor(None, _do_http, action, params) + if action in ("snmp.get", "snmp.walk"): + return await loop.run_in_executor(None, _do_snmp, action, params) + if action == "snmp.printer": + return await loop.run_in_executor(None, _do_snmp_printer, params) return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."} except Exception as exc: return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"} @@ -535,6 +550,94 @@ def _do_http(action: str, params: dict) -> dict: }} +def _snmp_run(args: list, timeout: float) -> tuple: + """Fuehrt ein net-snmp-CLI-Tool aus. Gibt (ok, stdout|fehlertext).""" + import subprocess + try: + r = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + except FileNotFoundError: + return False, "snmp-Tools fehlen im Container (Paket 'snmp' im Dockerfile)." + except subprocess.TimeoutExpired: + return False, "SNMP-Timeout — Geraet antwortet nicht (community/version/IP pruefen)." + if r.returncode != 0: + return False, (r.stderr or r.stdout or "SNMP-Fehler").strip()[:200] + return True, r.stdout + + +def _snmp_base_args(params: dict) -> list: + community = str(params.get("community") or SNMP_COMMUNITY) + version = str(params.get("version") or SNMP_VERSION) + # -t Timeout(s), -r Retries: schnell scheitern statt haengen. + return ["-v", version, "-c", community, "-t", "2", "-r", "1"] + + +def _snmp_target(params: dict) -> str: + return (params.get("ip") or params.get("host") or params.get("device") or "").strip() + + +def _do_snmp(action: str, params: dict) -> dict: + """Generisches snmp.get / snmp.walk. + params: {ip|host, oid, community?='public', version?='2c'}.""" + ip = _snmp_target(params) + if not ip: + return {"ok": False, "error": "ip/host erforderlich."} + oid = str(params.get("oid") or "").strip() + if not oid: + return {"ok": False, "error": "oid erforderlich (z.B. 1.3.6.1.2.1.1.5.0 fuer sysName)."} + tool = "snmpwalk" if action == "snmp.walk" else "snmpget" + # -OQ: OID = Wert, ohne Typannotation; numerische OIDs brauchen keine MIB-Files. + args = [tool, "-OQ", *_snmp_base_args(params), ip, oid] + ok, out = _snmp_run(args, SNMP_TIMEOUT_SEC) + if not ok: + return {"ok": False, "error": out} + lines = [ln.strip() for ln in out.splitlines() if ln.strip()] + return {"ok": True, "result": {"ip": ip, "oid": oid, "lines": lines[:200]}} + + +def _snmp_walk_values(ip: str, base: list, oid: str) -> list: + """snmpwalk -Oqv (nur Werte, in OID-Index-Reihenfolge).""" + ok, out = _snmp_run(["snmpwalk", "-Oqv", *base, ip, oid], SNMP_TIMEOUT_SEC) + if not ok: + return [] + return [ln.strip().strip('"') for ln in out.splitlines() if ln.strip()] + + +def _do_snmp_printer(params: dict) -> dict: + """Komfort: liest die Verbrauchsmaterialien (Tinte/Toner) aus der Printer-MIB + und rechnet Fuellstaende in Prozent. params: {ip|host, community?, version?}.""" + ip = _snmp_target(params) + if not ip: + return {"ok": False, "error": "ip/host erforderlich."} + base = _snmp_base_args(params) + descs = _snmp_walk_values(ip, base, SNMP_SUPPLY_DESC) + if not descs: + return {"ok": False, "error": + "Keine Printer-MIB-Daten (Geraet unterstuetzt kein SNMP, falsche " + "community/version, oder es ist kein Drucker)."} + lvls = _snmp_walk_values(ip, base, SNMP_SUPPLY_LVL) + maxs = _snmp_walk_values(ip, base, SNMP_SUPPLY_MAX) + supplies = [] + for i, name in enumerate(descs): + lvl = _to_int(lvls[i]) if i < len(lvls) else None + mx = _to_int(maxs[i]) if i < len(maxs) else None + percent = None + if lvl is not None and mx and mx > 0 and lvl >= 0: + percent = round(lvl / mx * 100) + elif lvl == -3: + percent = "vorhanden (Stand unbekannt)" # RFC: some remaining + elif lvl in (-1, -2): + percent = "unbekannt" + supplies.append({"name": name, "level": lvl, "max": mx, "percent": percent}) + return {"ok": True, "result": {"ip": ip, "supplies": supplies}} + + +def _to_int(s: str): + try: + return int(str(s).strip()) + except (TypeError, ValueError): + return None + + # ─── Helpers ──────────────────────────────────────────────────────── def _slug(s: str) -> str: