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:
+104
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user