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