feat(satellite): SNMP-Aktionen (snmp.get/walk/printer) — Drucker-Tinte zuverlaessig

HTML-Scrapen der Drucker-Statusseite ist fragil (grosse Seite, Werte teils in
JS/Grafik). SNMP/Printer-MIB liefert die Fuellstaende als saubere Zahlen.

- Dockerfile: net-snmp-CLI (Paket 'snmp') installiert.
- satellite.py: Aktionen snmp.get / snmp.walk (generisch, params ip+oid) und
  snmp.printer (Komfort: liest prtMarkerSupplies aus der Printer-MIB und rechnet
  je Patrone name+level+max+percent). net-snmp via subprocess, numerische OIDs
  (keine MIB-Files noetig), -t2 -r1 + Timeout = schnell scheitern statt haengen.
  RFC-Sonderwerte (-1/-2 unbekannt, -3 vorhanden) behandelt.
- CONTROL_ALLOWLIST-Default + .env.example um snmp.* erweitert, SNMP_*-Envs
  dokumentiert.
- agent.py + seed_rules.py: satellite_command-Tool + Drucker-Beispiel — snmp.
  printer ist jetzt der BEVORZUGTE Weg fuer Tinte, http.get nur Fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 17:01:08 +02:00
co-authored by Claude Opus 4.8
parent c2472a7958
commit 02303c35ee
5 changed files with 139 additions and 12 deletions
+6 -1
View File
@@ -1220,8 +1220,13 @@ META_TOOLS = [
"Beispiel YouTube-Video auf Fire TV: action='dial.launch', " "Beispiel YouTube-Video auf Fire TV: action='dial.launch', "
"device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere " "device='Fire TV', params={'app':'YouTube','v':'<videoId>'}. Weitere "
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, " "Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
"'snmp.printer' (params={'ip':'<drucker-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 " "'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 " "Seiten NICHT blind paginieren: setze params['contains'] (String oder "
"Liste) — dann kommen nur Zeilen zurueck, die einen der Begriffe enthalten " "Liste) — dann kommen nur Zeilen zurueck, die einen der Begriffe enthalten "
"(z.B. contains=['ink','toner','cyan','magenta','yellow','black','%'] fuer " "(z.B. contains=['ink','toner','cyan','magenta','yellow','black','%'] fuer "
+8 -8
View File
@@ -492,14 +492,14 @@ SEED_RULES: List[dict] = [
" 3. `satellite_command(...)` — Aktion ausfuehren.\n" " 3. `satellite_command(...)` — Aktion ausfuehren.\n"
"\n" "\n"
"Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im " "Beispiel 'Patronenstand vom Drucker zuhause': satellite_list -> Satellit im "
"Heimnetz online? -> satellite_command(satellite='<Heim>', action='http.get', " "Heimnetz online? -> BEVORZUGT satellite_command(satellite='<Heim>', "
"params={'url':'http://<drucker-ip>/general/status.html', " "action='snmp.printer', params={'ip':'<drucker-ip>'}) -> liefert supplies mit "
"'contains':['ink','toner','cyan','magenta','yellow','black','%']}) -> aus den " "name + percent je Patrone (BK/C/M/Y), zuverlaessig aus der Printer-MIB. "
"gefilterten Zeilen die Fuellstaende (BK/C/M/Y) vorlesen. Der contains-Filter " "NUR falls SNMP nichts liefert, als Fallback die HTML-Statusseite: "
"holt nur die relevanten Zeilen aus einer grossen Statusseite (sonst wird der " "action='http.get', params={'url':'http://<drucker-ip>/general/status.html', "
"Body bei max_chars, Default 20000, abgeschnitten). Kommt nichts Brauchbares: " "'contains':['ink','toner','cyan','magenta','yellow','black','%']} — der "
"andere Statuspfade probieren (/general/information.html?kind=item, SNMP-UI) " "contains-Filter zieht die relevanten Zeilen (sonst wird der Body bei "
"oder offset erhoehen. NICHT aus dem Gedaechtnis raten.\n" "max_chars, Default 20000, abgeschnitten). NICHT aus dem Gedaechtnis raten.\n"
"\n" "\n"
"Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n" "Beispiel 'spiel YouTube-Video auf dem Buero-Stick':\n"
" satellite_command(satellite='Buero', device='Fire TV', " " satellite_command(satellite='Buero', device='Fire TV', "
+15 -2
View File
@@ -24,9 +24,22 @@ CONTROL_ENABLED=true
# Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt. # Erlaubte Steuer-Aktionen (kommagetrennt). Alles andere wird abgelehnt.
# dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV) # dial.launch App-Launch via DIAL (z.B. YouTube-Video auf Fire TV / Smart-TV)
# wol Wake-on-LAN (Geraet per MAC aufwecken) # 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 # 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) ─────────────────────────────────── # ─── Discovery-Tuning (optional) ───────────────────────────────────
SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall SCAN_INTERVAL_SEC=300 # Hintergrund-Rescan-Intervall
+6
View File
@@ -5,6 +5,12 @@ FROM python:3.12-slim
WORKDIR /app 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+104 -1
View File
@@ -113,7 +113,8 @@ SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).stri
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False) CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [ CONTROL_ALLOWLIST = [
a.strip() for a in 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() 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 = 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") 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 HEARTBEAT_SEC = 25
# mDNS-Servicetypen, die fuer ARIA interessant sind. # 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) return await loop.run_in_executor(None, _do_wol, params)
if action in ("http.get", "http.post"): if action in ("http.get", "http.post"):
return await loop.run_in_executor(None, _do_http, action, params) 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."} return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc: except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {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 ──────────────────────────────────────────────────────── # ─── Helpers ────────────────────────────────────────────────────────
def _slug(s: str) -> str: def _slug(s: str) -> str: