- kern/platten.py: SMART aus pvesh, smartctl und SNMP in einer Form; rot bei SMART FAILED, kritischer Warnung, aufgebrauchter Reserve, ≥ 90 % verbraucht; gelb bei ≥ 80 %, Hitze, gewachsenen Fehlerzählern (Ausgangslage je Platte, „Zur Kenntnis genommen“ setzt sie neu) - Ausführer: stündlich disks/list + disks/smart im Bericht (nur lesend) - services/homelab/nas_snmp.py: kleiner SNMPv2c-GetBulk-Leser ohne neue Abhängigkeit, QNAP hdTable/diskTable, Zugang 0600, Einstellungen „Verbindung testen und speichern“ - services/platten.py: NVMe der KI-Box über sudo smartctl (smartmontools am 26.09. nachinstalliert) - Wächter beider Rollen, Plattenfeld in beiden Cockpits, Doku Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
13 KiB
Python
326 lines
13 KiB
Python
"""Die Platten des NAS über SNMP (Ausbauplan „Später“, Punkt 18, seit 26.09.2026).
|
|
|
|
User-Entscheid 25.09.2026: Der User schaltet am QNAP SNMP v1/v2c ein (Systemsteuerung → Netzwerk- und Dateidienste →
|
|
SNMP) und trägt den Zugangsnamen („Community“) unter Einstellungen → Homelab ein. Vor dem Speichern fragt dieser Teil
|
|
das NAS einmal damit; antwortet es nicht, wird nichts gespeichert. Der Zugangsname liegt in <Datenordner>/nas-snmp.json
|
|
(0600) und erscheint nie wieder in einer Antwort.
|
|
|
|
Ein kleiner SNMPv2c-Leser (GetBulk über UDP 161) statt einer neuen Abhängigkeit. Gelesen wird die Plattentabelle der
|
|
QNAP-MIB: zuerst die ältere (NAS-MIB, hdTable — die meisten QTS-Versionen liefern sie noch), sonst die neuere
|
|
(QTS-MIB, diskTable), deren Spalten nach Inhalt erkannt werden. Ein falscher Zugangsname fällt bei SNMP v2c nur als
|
|
Schweigen auf: Das NAS antwortet dann gar nicht.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import socket
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from kern.einstellungen import einstellungen
|
|
|
|
from services.homelab import kanal
|
|
|
|
PORT = 161
|
|
ZEITLIMIT_S = 3.0
|
|
CACHE_S = 600
|
|
SYS_DESCR = "1.3.6.1.2.1.1.1"
|
|
HD_TABELLE = "1.3.6.1.4.1.24681.1.2.11.1" # NAS-MIB hdTable: 2 Name, 3 Temperatur, 4 Status, 5 Modell, 7 SMART
|
|
DISK_TABELLE = "1.3.6.1.4.1.24681.1.4.1.1.1.1.5.2.1" # QTS-MIB diskTable (neuere Firmware)
|
|
GESUND = {"good", "normal", "ok"}
|
|
KRANK = {"abnormal", "bad", "error"}
|
|
_lock = threading.Lock()
|
|
_cache: dict[str, tuple[float, dict]] = {}
|
|
|
|
|
|
class SnmpFehler(Exception):
|
|
pass
|
|
|
|
|
|
# --- BER: gerade genug für GetBulk und die Antwort -----------------------------------------------------------------
|
|
|
|
def _laenge(n: int) -> bytes:
|
|
if n < 0x80:
|
|
return bytes([n])
|
|
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
|
return bytes([0x80 | len(b)]) + b
|
|
|
|
|
|
def _tlv(tag: int, inhalt: bytes) -> bytes:
|
|
return bytes([tag]) + _laenge(len(inhalt)) + inhalt
|
|
|
|
|
|
def _int(n: int) -> bytes:
|
|
return _tlv(0x02, n.to_bytes(max(1, (n.bit_length() + 8) // 8), "big", signed=True))
|
|
|
|
|
|
def _oid(oid: str) -> bytes:
|
|
teile = [int(t) for t in oid.strip(".").split(".")]
|
|
inhalt = bytearray([40 * teile[0] + teile[1]])
|
|
for t in teile[2:]:
|
|
stuecke = [t & 0x7F]
|
|
t >>= 7
|
|
while t:
|
|
stuecke.insert(0, 0x80 | (t & 0x7F))
|
|
t >>= 7
|
|
inhalt += bytes(stuecke)
|
|
return _tlv(0x06, bytes(inhalt))
|
|
|
|
|
|
def anfrage(community: str, oid: str, anfrage_id: int, wiederholungen: int = 25) -> bytes:
|
|
"""Eine SNMPv2c-GetBulk-Anfrage ab oid."""
|
|
bindung = _tlv(0x30, _oid(oid) + b"\x05\x00")
|
|
pdu = _tlv(0xA5, _int(anfrage_id) + _int(0) + _int(wiederholungen) + _tlv(0x30, bindung))
|
|
return _tlv(0x30, _int(1) + _tlv(0x04, community.encode("utf-8")) + pdu)
|
|
|
|
|
|
def _lies(daten: bytes, pos: int) -> tuple[int, bytes, int]:
|
|
tag = daten[pos]
|
|
laenge = daten[pos + 1]
|
|
pos += 2
|
|
if laenge & 0x80:
|
|
n = laenge & 0x7F
|
|
laenge = int.from_bytes(daten[pos:pos + n], "big")
|
|
pos += n
|
|
return tag, daten[pos:pos + laenge], pos + laenge
|
|
|
|
|
|
def _oid_text(b: bytes) -> str:
|
|
teile = [b[0] // 40, b[0] % 40]
|
|
wert = 0
|
|
for x in b[1:]:
|
|
wert = (wert << 7) | (x & 0x7F)
|
|
if not x & 0x80:
|
|
teile.append(wert)
|
|
wert = 0
|
|
return ".".join(str(t) for t in teile)
|
|
|
|
|
|
def _wert(tag: int, inhalt: bytes) -> object:
|
|
if tag == 0x02:
|
|
return int.from_bytes(inhalt, "big", signed=True)
|
|
if tag == 0x04:
|
|
return inhalt.decode("utf-8", "replace").strip("\x00").strip()
|
|
if tag in (0x41, 0x42, 0x43, 0x46): # Counter32, Gauge32, TimeTicks, Counter64
|
|
return int.from_bytes(inhalt, "big")
|
|
if tag == 0x06:
|
|
return _oid_text(inhalt)
|
|
if tag == 0x40:
|
|
return ".".join(str(b) for b in inhalt)
|
|
return None # NULL, noSuchObject, noSuchInstance, endOfMibView
|
|
|
|
|
|
def antwort(daten: bytes) -> tuple[int, int, list[tuple[str, object, int]]]:
|
|
"""(Anfrage-Nr., Fehlerstatus, [(OID, Wert, Typ)]) aus einer Response-PDU."""
|
|
try:
|
|
_, nachricht, _ = _lies(daten, 0)
|
|
_, _, pos = _lies(nachricht, 0) # Version
|
|
_, _, pos = _lies(nachricht, pos) # Community
|
|
tag, pdu, _ = _lies(nachricht, pos)
|
|
if tag != 0xA2:
|
|
raise SnmpFehler("keine Antwort-PDU")
|
|
_, nr, p = _lies(pdu, 0)
|
|
_, fehler, p = _lies(pdu, p)
|
|
_, _, p = _lies(pdu, p)
|
|
_, bindungen, _ = _lies(pdu, p)
|
|
werte, q = [], 0
|
|
while q < len(bindungen):
|
|
_, bindung, q = _lies(bindungen, q)
|
|
_, oid, r = _lies(bindung, 0)
|
|
wtag, winhalt, _ = _lies(bindung, r)
|
|
werte.append((_oid_text(oid), _wert(wtag, winhalt), wtag))
|
|
except (IndexError, ValueError) as exc:
|
|
raise SnmpFehler("unlesbare Antwort") from exc
|
|
return int.from_bytes(nr, "big", signed=True), int.from_bytes(fehler, "big"), werte
|
|
|
|
|
|
def walk(adresse: str, community: str, basis: str, zeitlimit: float | None = None, grenze: int = 2000,
|
|
port: int | None = None) -> list:
|
|
"""Alle (OID, Wert) unter basis. SnmpFehler bei Schweigen (falscher Zugangsname, SNMP aus) oder Fehlern."""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.settimeout(zeitlimit or ZEITLIMIT_S)
|
|
ergebnis: list[tuple[str, object]] = []
|
|
oid, nr = basis, random.randint(1, 1_000_000)
|
|
try:
|
|
while len(ergebnis) < grenze:
|
|
nr += 1
|
|
sock.sendto(anfrage(community, oid, nr), (adresse, port or PORT))
|
|
try:
|
|
daten, _ = sock.recvfrom(65535)
|
|
except TimeoutError as exc:
|
|
raise SnmpFehler("keine Antwort") from exc
|
|
rnr, fehler, werte = antwort(daten)
|
|
if rnr != nr:
|
|
continue
|
|
if fehler:
|
|
raise SnmpFehler(f"Fehlerstatus {fehler}")
|
|
fertig = not werte
|
|
for o, w, tag in werte:
|
|
if tag == 0x82 or not o.startswith(basis + "."):
|
|
fertig = True
|
|
break
|
|
ergebnis.append((o, w))
|
|
if fertig:
|
|
break
|
|
oid = werte[-1][0]
|
|
except OSError as exc:
|
|
raise SnmpFehler(f"Netzfehler {exc.__class__.__name__}") from exc
|
|
finally:
|
|
sock.close()
|
|
return ergebnis
|
|
|
|
|
|
# --- Plattentabelle der QNAP-MIB ----------------------------------------------------------------------------------
|
|
|
|
def _spalten(werte: list, basis: str) -> dict[str, dict[int, object]]:
|
|
"""{Zeile: {Spalte: Wert}} aus OIDs der Form <basis>.<spalte>.<zeile>."""
|
|
zeilen: dict[str, dict[int, object]] = {}
|
|
for oid, wert in werte:
|
|
rest = oid[len(basis) + 1:].split(".")
|
|
if len(rest) >= 2 and rest[0].isdigit():
|
|
zeilen.setdefault(".".join(rest[1:]), {})[int(rest[0])] = wert
|
|
return zeilen
|
|
|
|
|
|
def platte(nr: str, name: object, modell: object, temperatur: object, smart: object, status: object = None) -> dict:
|
|
s = str(smart or "").strip().lower()
|
|
temp = re.search(r"-?\d+", str(temperatur)) if temperatur is not None else None
|
|
return {"id": f"nas:{nr}:{modell or name}", "name": str(modell or name or f"Platte {nr}").strip(),
|
|
"wo": f"NAS {name or nr}", "typ": "hdd", "groesse": None,
|
|
"gesund": True if s in GESUND else False if s in KRANK else None,
|
|
"smart_warnung": s == "warning", "smart": str(smart or "") or None,
|
|
"verbraucht": None, "reserve": None, "reserve_grenze": None, "warnung": 0,
|
|
"temperatur": int(temp.group(0)) if temp else None, "stunden": None,
|
|
"fehler": {}, "status": status}
|
|
|
|
|
|
def platten_aus_hd(werte: list) -> list[dict]:
|
|
"""NAS-MIB hdTable: 2 Name, 3 Temperatur („35 C/95 F“), 4 Status (0 = bereit), 5 Modell, 7 SMART („GOOD“)."""
|
|
ergebnis = []
|
|
for nr, z in sorted(_spalten(werte, HD_TABELLE).items()):
|
|
if z.get(4) == -5: # kein Laufwerk im Schacht
|
|
continue
|
|
ergebnis.append(platte(nr, z.get(2), z.get(5), z.get(3), z.get(7), z.get(4)))
|
|
return ergebnis
|
|
|
|
|
|
def platten_aus_disk(werte: list) -> list[dict]:
|
|
"""QTS-MIB diskTable, Spalten nach Inhalt: SMART-Wort, Temperatur (Zahl zwischen 5 und 90), längster Text = Modell."""
|
|
ergebnis = []
|
|
for nr, z in sorted(_spalten(werte, DISK_TABELLE).items()):
|
|
texte = {k: v for k, v in z.items() if isinstance(v, str) and v}
|
|
smart = next((v for v in texte.values() if v.strip().lower() in GESUND | KRANK | {"warning"}), None)
|
|
temperatur = next((v for k, v in z.items() if isinstance(v, int) and 5 <= v <= 90 and k > 3), None)
|
|
modell = max((v for v in texte.values() if v != smart), key=len, default=None)
|
|
ergebnis.append(platte(nr, f"Platte {nr}", modell, temperatur, smart))
|
|
return ergebnis
|
|
|
|
|
|
# --- Zugang, Abfrage, Oberfläche ---------------------------------------------------------------------------------
|
|
|
|
def _pfad() -> Path:
|
|
return einstellungen().daten_dir / "nas-snmp.json"
|
|
|
|
|
|
def zugang() -> dict | None:
|
|
"""{"community", "adresse"} oder None."""
|
|
try:
|
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, UnicodeDecodeError):
|
|
return None
|
|
if not isinstance(daten, dict) or not isinstance(daten.get("community"), str) or not daten["community"]:
|
|
return None
|
|
return {"community": daten["community"], "adresse": str(daten.get("adresse") or "") or None}
|
|
|
|
|
|
def zugang_speichern(community: str, adresse: str | None) -> None:
|
|
pfad = _pfad()
|
|
tmp = pfad.with_name(pfad.name + ".tmp")
|
|
with _lock:
|
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.unlink(missing_ok=True)
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump({"community": community, "adresse": adresse}, f)
|
|
os.replace(tmp, pfad)
|
|
_cache.clear()
|
|
|
|
|
|
def zugang_loeschen() -> bool:
|
|
with _lock:
|
|
pfad = _pfad()
|
|
da = pfad.exists()
|
|
pfad.unlink(missing_ok=True)
|
|
_cache.clear()
|
|
return da
|
|
|
|
|
|
def nas_adresse() -> str | None:
|
|
"""Das NAS aus dem Bericht (die Einbindungen des Proxmox-Hosts), sonst None."""
|
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
|
for m in host.get("netzlaufwerke") or []:
|
|
if m.get("server"):
|
|
return str(m["server"])
|
|
return None
|
|
|
|
|
|
def lesen(adresse: str, community: str) -> dict:
|
|
"""{"ok", "system", "platten"} oder {"ok": False, "detail"} — ohne den Zugangsnamen."""
|
|
try:
|
|
system = walk(adresse, community, SYS_DESCR, grenze=1)
|
|
platten = platten_aus_hd(walk(adresse, community, HD_TABELLE))
|
|
if not platten:
|
|
platten = platten_aus_disk(walk(adresse, community, DISK_TABELLE))
|
|
except SnmpFehler as exc:
|
|
if str(exc) == "keine Antwort":
|
|
return {"ok": False, "detail": f"Das NAS ({adresse}) antwortet nicht über SNMP. Ist SNMP v1/v2c an, und "
|
|
"stimmt der Zugangsname (Community)?"}
|
|
return {"ok": False, "detail": f"SNMP-Abfrage an {adresse} gescheitert: {exc}."}
|
|
return {"ok": True, "system": str(system[0][1]) if system else None, "platten": platten}
|
|
|
|
|
|
def stand() -> dict:
|
|
"""Für Oberfläche und Wächter: höchstens alle CACHE_S neu gefragt. {"eingerichtet", "ok", "platten", "detail"}."""
|
|
z = zugang()
|
|
if not z:
|
|
return {"eingerichtet": False, "ok": False, "platten": [], "detail": None}
|
|
adresse = z["adresse"] or nas_adresse()
|
|
if not adresse:
|
|
return {"eingerichtet": True, "ok": False, "platten": [], "detail": "Die Adresse des NAS ist unbekannt."}
|
|
jetzt = time.time()
|
|
with _lock:
|
|
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
|
|
return eintrag[1]
|
|
ergebnis = {"eingerichtet": True, **lesen(adresse, z["community"])}
|
|
ergebnis.setdefault("platten", [])
|
|
with _lock:
|
|
_cache[adresse] = (jetzt, ergebnis)
|
|
return ergebnis
|
|
|
|
|
|
def setzen(community: str, adresse: str | None) -> dict:
|
|
"""POST /api/homelab/einstellungen/nas-snmp: erst das NAS fragen, dann speichern."""
|
|
community, adresse = (community or "").strip(), (adresse or "").strip() or None
|
|
if not community or len(community) > 200 or (adresse and not re.fullmatch(r"[\w.:-]{1,253}", adresse)):
|
|
return {"ok": False, "detail": "Zugangsname (Community) eintragen, die Adresse nur, wenn sie abweicht. Nichts "
|
|
"gespeichert."}
|
|
ziel = adresse or nas_adresse()
|
|
if not ziel:
|
|
return {"ok": False, "detail": "Die Adresse des NAS ist unbekannt — bitte mit eintragen. Nichts gespeichert."}
|
|
ergebnis = lesen(ziel, community)
|
|
if not ergebnis["ok"]:
|
|
return {"ok": False, "detail": ergebnis["detail"] + " Nichts gespeichert."}
|
|
try:
|
|
zugang_speichern(community, adresse)
|
|
except OSError as exc:
|
|
return {"ok": False, "detail": f"Das NAS antwortet, aber der Zugang ließ sich nicht speichern "
|
|
f"({exc.__class__.__name__})."}
|
|
platten = ergebnis["platten"]
|
|
gesund = sum(1 for p in platten if p["gesund"])
|
|
return {"ok": True, "text": f"Das NAS antwortet ({(ergebnis.get('system') or 'SNMP')[:80]}): "
|
|
+ (f"{len(platten)} Platten, davon {gesund} gesund." if platten
|
|
else "Die Plattentabelle ist leer — die Firmware nennt sie vielleicht anders.")}
|