Später · 18 Plattengesundheit: Proxmox-Host, NAS über SNMP, NVMe der KI-Box
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5325544d89
commit
904598031a
@@ -0,0 +1,201 @@
|
||||
"""Plattengesundheit (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): SMART-Werte vereinheitlichen und bewerten.
|
||||
|
||||
Quellen: der Proxmox-Host (Ausführer: pvesh disks/list und disks/smart), die KI-Box (smartctl -j) und das NAS (SNMP,
|
||||
services/homelab/nas_snmp.py). Einheitliche Form je Platte:
|
||||
{"id", "name", "wo", "typ", "groesse", "gesund": True/False/None, "verbraucht": % oder None,
|
||||
"reserve": % oder None, "reserve_grenze": % oder None, "warnung": 0 oder Bits, "temperatur": °C oder None,
|
||||
"stunden": h oder None, "fehler": {"medien", "umgelagert", "ausstehend", "unkorrigierbar", "kabel": Zahl}}
|
||||
Gemeldet wird, was schlecht ist oder schlechter wird (User-Entscheid „wie empfohlen“ 25.09.2026):
|
||||
rot die Platte hält sich selbst für krank (SMART FAILED, kritische Warnung, Reserve unter der Grenze) oder ist
|
||||
zu 90 % verbraucht
|
||||
gelb zu 80 % verbraucht, zu heiß, oder ein Fehlerzähler ist seit dem letzten „Zur Kenntnis genommen“ gewachsen
|
||||
Fehler, die schon beim ersten Blick da waren, gelten als Ausgangslage (basis) — sonst meldete eine ältere, aber stabile
|
||||
Platte für immer. Die Ausgangslage liegt je Instanz in <Datenordner>/platten-basis.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
VERBRAUCHT_GELB, VERBRAUCHT_ROT = 80, 90
|
||||
HEISS = {"nvme": 70, "ssd": 60, "hdd": 55}
|
||||
FEHLER_NAME = {"medien": "Medienfehler", "umgelagert": "umgelagerte Sektoren", "ausstehend": "wartende Sektoren",
|
||||
"unkorrigierbar": "unkorrigierbare Sektoren", "kabel": "Übertragungsfehler (Kabel)"}
|
||||
# SATA-Attribute (Nummer → Fehlerart) und die Temperatur.
|
||||
ATA_FEHLER = {5: "umgelagert", 197: "ausstehend", 198: "unkorrigierbar", 199: "kabel"}
|
||||
ATA_TEMPERATUR = (194, 190)
|
||||
|
||||
|
||||
def _zahl(text: object) -> int | None:
|
||||
m = re.search(r"-?\d[\d,.]*", str(text or ""))
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(m.group(0).replace(",", "").replace(".", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def aus_nvme_text(text: str) -> dict:
|
||||
"""Die NVMe-Werte aus dem Text von smartctl (so liefert ihn pvesh disks/smart)."""
|
||||
werte: dict[str, str] = {}
|
||||
for zeile in (text or "").splitlines():
|
||||
if ":" in zeile:
|
||||
k, v = zeile.split(":", 1)
|
||||
werte.setdefault(k.strip(), v.strip())
|
||||
warnung = werte.get("Critical Warning")
|
||||
return {
|
||||
"warnung": int(warnung, 16) if warnung and re.fullmatch(r"0x[0-9a-fA-F]+", warnung) else _zahl(warnung) or 0,
|
||||
"temperatur": _zahl(werte.get("Temperature")),
|
||||
"reserve": _zahl(werte.get("Available Spare")),
|
||||
"reserve_grenze": _zahl(werte.get("Available Spare Threshold")),
|
||||
"verbraucht": _zahl(werte.get("Percentage Used")),
|
||||
"stunden": _zahl(werte.get("Power On Hours")),
|
||||
"fehler": {"medien": _zahl(werte.get("Media and Data Integrity Errors")) or 0},
|
||||
}
|
||||
|
||||
|
||||
def aus_ata_attributen(attribute: list) -> dict:
|
||||
"""SATA: Fehlerzähler und Temperatur aus den Attributen (pvesh disks/smart „attributes“)."""
|
||||
fehler: dict[str, int] = {}
|
||||
temperatur = None
|
||||
for a in attribute or []:
|
||||
try:
|
||||
nr = int(a.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
roh = _zahl(str(a.get("raw") or "").split()[0] if str(a.get("raw") or "").split() else None)
|
||||
if nr in ATA_FEHLER and roh is not None:
|
||||
fehler[ATA_FEHLER[nr]] = roh
|
||||
if nr in ATA_TEMPERATUR and temperatur is None and roh is not None:
|
||||
temperatur = roh
|
||||
return {"fehler": fehler, "temperatur": temperatur}
|
||||
|
||||
|
||||
def aus_proxmox(disk: dict, smart: dict | None) -> dict:
|
||||
"""Eine Platte des Proxmox-Hosts aus pvesh disks/list (+ disks/smart)."""
|
||||
typ = str(disk.get("type") or "").lower() or "unbekannt"
|
||||
health = str((smart or {}).get("health") or disk.get("health") or "").upper()
|
||||
platte = {"id": f"pve:{disk.get('serial') or disk.get('devpath')}", "name": disk.get("model") or disk.get("devpath"),
|
||||
"wo": f"Proxmox-Host {disk.get('devpath')}", "typ": typ, "groesse": disk.get("size"),
|
||||
"gesund": True if health in ("PASSED", "OK") else False if health == "FAILED" else None,
|
||||
"verbraucht": None, "reserve": None, "reserve_grenze": None, "warnung": 0, "temperatur": None,
|
||||
"stunden": None, "fehler": {}}
|
||||
wearout = disk.get("wearout")
|
||||
if isinstance(wearout, (int, float)) and 0 <= wearout <= 100:
|
||||
platte["verbraucht"] = 100 - int(wearout) # Proxmox zeigt, was noch übrig ist
|
||||
if smart:
|
||||
if (smart.get("type") == "text" or typ == "nvme") and smart.get("text"):
|
||||
werte = aus_nvme_text(smart["text"])
|
||||
platte.update({k: v for k, v in werte.items() if v is not None and k != "fehler"})
|
||||
platte["fehler"] = werte["fehler"]
|
||||
elif smart.get("attributes"):
|
||||
platte.update(aus_ata_attributen(smart["attributes"]))
|
||||
return platte
|
||||
|
||||
|
||||
def aus_smartctl(d: dict, geraet: str, wo: str) -> dict:
|
||||
"""Eine Platte aus `smartctl -j -a` (KI-Box)."""
|
||||
n = d.get("nvme_smart_health_information_log") or {}
|
||||
status = (d.get("smart_status") or {}).get("passed")
|
||||
typ = "nvme" if n else ("ssd" if (d.get("rotation_rate") == 0) else "hdd")
|
||||
platte = {"id": f"{wo}:{d.get('serial_number') or geraet}", "name": d.get("model_name") or geraet,
|
||||
"wo": f"{wo} {geraet}", "typ": typ, "groesse": (d.get("user_capacity") or {}).get("bytes"),
|
||||
"gesund": status if isinstance(status, bool) else None, "verbraucht": n.get("percentage_used"),
|
||||
"reserve": n.get("available_spare"), "reserve_grenze": n.get("available_spare_threshold"),
|
||||
"warnung": int(n.get("critical_warning") or 0), "temperatur": (d.get("temperature") or {}).get("current"),
|
||||
"stunden": (d.get("power_on_time") or {}).get("hours") or n.get("power_on_hours"), "fehler": {}}
|
||||
if n:
|
||||
platte["fehler"] = {"medien": int(n.get("media_errors") or 0)}
|
||||
else:
|
||||
tabelle = [{"id": a.get("id"), "raw": str((a.get("raw") or {}).get("value", ""))}
|
||||
for a in (d.get("ata_smart_attributes") or {}).get("table") or []]
|
||||
platte.update(aus_ata_attributen(tabelle))
|
||||
return platte
|
||||
|
||||
|
||||
def bewerten(p: dict, basis: dict | None) -> tuple[str, list[str]]:
|
||||
"""(Stufe, Sätze). basis = die Fehlerzähler, die als Ausgangslage gelten."""
|
||||
stufe, saetze = "ok", []
|
||||
rang = {"ok": 0, "gelb": 1, "rot": 2}
|
||||
|
||||
def schlimmer(neu: str, satz: str) -> None:
|
||||
nonlocal stufe
|
||||
saetze.append(satz)
|
||||
if rang[neu] > rang[stufe]:
|
||||
stufe = neu
|
||||
|
||||
if p.get("gesund") is False:
|
||||
schlimmer("rot", "meldet sich selbst als defekt (SMART)")
|
||||
if p.get("smart_warnung"):
|
||||
schlimmer("gelb", "SMART meldet eine Warnung")
|
||||
if p.get("warnung"):
|
||||
schlimmer("rot", f"meldet eine kritische Warnung (0x{int(p['warnung']):02x})")
|
||||
reserve, grenze = p.get("reserve"), p.get("reserve_grenze")
|
||||
if isinstance(reserve, int) and isinstance(grenze, int) and grenze > 0 and reserve <= grenze:
|
||||
schlimmer("rot", f"Ersatzblöcke fast aufgebraucht ({reserve} %)")
|
||||
v = p.get("verbraucht")
|
||||
if isinstance(v, (int, float)) and v >= VERBRAUCHT_ROT:
|
||||
schlimmer("rot", f"zu {int(v)} % verbraucht — Ersatz einplanen")
|
||||
elif isinstance(v, (int, float)) and v >= VERBRAUCHT_GELB:
|
||||
schlimmer("gelb", f"zu {int(v)} % verbraucht")
|
||||
t, heiss = p.get("temperatur"), HEISS.get(p.get("typ") or "", 60)
|
||||
if isinstance(t, (int, float)) and t >= heiss:
|
||||
schlimmer("gelb", f"{int(t)} °C warm (ab {heiss} °C zu heiß)")
|
||||
for art, n in (p.get("fehler") or {}).items():
|
||||
alt = (basis or {}).get(art, n)
|
||||
if isinstance(n, int) and isinstance(alt, int) and n > alt:
|
||||
schlimmer("gelb", f"{FEHLER_NAME.get(art, art)}: {alt} → {n}")
|
||||
return stufe, saetze
|
||||
|
||||
|
||||
# --- Ausgangslage je Instanz -------------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "platten-basis.json"
|
||||
|
||||
|
||||
def basis_lesen() -> dict[str, dict]:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
def _basis_schreiben(daten: dict) -> None:
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def bewertet(platten: list[dict]) -> list[dict]:
|
||||
"""Die Platten mit stufe und saetze. Neue Platten bekommen ihre Ausgangslage beim ersten Blick."""
|
||||
basis = basis_lesen()
|
||||
neu = {p["id"]: dict(p.get("fehler") or {}) for p in platten if p.get("id") and p["id"] not in basis}
|
||||
if neu:
|
||||
_basis_schreiben({**basis, **neu})
|
||||
basis = {**basis, **neu}
|
||||
ergebnis = []
|
||||
for p in platten:
|
||||
stufe, saetze = bewerten(p, basis.get(p.get("id")))
|
||||
ergebnis.append({**p, "stufe": stufe, "saetze": saetze})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def hinnehmen(pid: str, platten: list[dict]) -> dict:
|
||||
"""„Zur Kenntnis genommen“: die heutigen Fehlerzähler werden die neue Ausgangslage."""
|
||||
p = next((x for x in platten if x.get("id") == pid), None)
|
||||
if p is None:
|
||||
return {"ok": False, "detail": "Diese Platte ist gerade nicht zu sehen."}
|
||||
_basis_schreiben({**basis_lesen(), pid: dict(p.get("fehler") or {})})
|
||||
return {"ok": True, "text": f"Zur Kenntnis genommen: {p.get('name')}. Gemeldet wird erst wieder, wenn die Fehler "
|
||||
"weiter wachsen."}
|
||||
|
||||
|
||||
def befund_titel(p: dict) -> str:
|
||||
return f"Platte {p.get('name')} ({p.get('wo')}): {p['saetze'][0]}" if p.get("saetze") else str(p.get("name"))
|
||||
@@ -156,6 +156,13 @@ def start() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/platten")
|
||||
def platten() -> dict:
|
||||
"""Plattengesundheit der KI-Box (smartctl, höchstens stündlich gelesen; seit 26.09.2026)."""
|
||||
from services import platten as box_platten
|
||||
return box_platten.lage()
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
return waechter.lese_stand()
|
||||
|
||||
@@ -134,6 +134,33 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
||||
|
||||
|
||||
@router.get("/platten")
|
||||
def platten() -> dict:
|
||||
"""Plattengesundheit: Proxmox-Host (Ausführer) und NAS (SNMP), bewertet (seit 26.09.2026)."""
|
||||
from services.homelab import platten as homelab_platten
|
||||
return homelab_platten.lage()
|
||||
|
||||
|
||||
@router.post("/einstellungen/nas-snmp")
|
||||
async def nas_snmp_setzen(request: Request) -> dict:
|
||||
"""{"community", "adresse"?}: erst das NAS fragen, dann speichern. Der Body wird selbst gelesen (keine 422 mit
|
||||
dem Zugangsnamen darin), die Abfrage läuft neben dem Ereignis-Takt."""
|
||||
from services.homelab import nas_snmp
|
||||
try:
|
||||
daten = json.loads(await request.body() or b"{}")
|
||||
except ValueError:
|
||||
daten = {}
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
return await asyncio.to_thread(nas_snmp.setzen, str(daten.get("community") or ""), str(daten.get("adresse") or ""))
|
||||
|
||||
|
||||
@router.delete("/einstellungen/nas-snmp")
|
||||
def nas_snmp_loeschen() -> dict:
|
||||
from services.homelab import nas_snmp
|
||||
da = nas_snmp.zugang_loeschen()
|
||||
return {"ok": True, "text": "Der SNMP-Zugang des NAS ist gelöscht." if da else "Es war kein SNMP-Zugang hinterlegt."}
|
||||
|
||||
|
||||
@router.get("/netz")
|
||||
def netz() -> dict:
|
||||
"""Die Geräte im Heimnetz aus der Nachbartabelle des Proxmox-Hosts, neue zuerst (seit 26.09.2026)."""
|
||||
|
||||
@@ -17,7 +17,7 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
|
||||
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz, nas_snmp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +46,9 @@ def stand() -> dict:
|
||||
"echt_quelle": arcane.echt_quelle()},
|
||||
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
|
||||
"benutzer": z[0] if z else None},
|
||||
# Seit 26.09.2026: SNMP-Zugang des NAS für die Plattengesundheit — nie der Zugangsname selbst.
|
||||
"nas_snmp": {"zugang": "hinterlegt" if (n := nas_snmp.zugang()) else "fehlt",
|
||||
"adresse": (n or {}).get("adresse") or nas_snmp.nas_adresse()},
|
||||
"karenz_h": round(karenz.karenz_s() / 3600),
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""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.")}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plattengesundheit im Homelab (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): die Platten des Proxmox-Hosts
|
||||
(Ausführer, stündlich im Bericht) und die des NAS (SNMP, services/homelab/nas_snmp.py), bewertet mit kern/platten.py.
|
||||
Der Wächter meldet nur, was schlecht ist oder schlechter wird; „Zur Kenntnis genommen“ macht die heutigen
|
||||
Fehlerzähler zur neuen Ausgangslage.
|
||||
"""
|
||||
|
||||
from kern import platten as kern_platten
|
||||
|
||||
from services.homelab import kanal, nas_snmp
|
||||
|
||||
|
||||
def host_platten() -> list[dict]:
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
return [kern_platten.aus_proxmox(e.get("disk") or {}, e.get("smart")) for e in host.get("platten") or []
|
||||
if isinstance(e, dict) and isinstance(e.get("disk"), dict)]
|
||||
|
||||
|
||||
def lage() -> dict:
|
||||
"""Für GET /api/homelab/platten."""
|
||||
nas = nas_snmp.stand()
|
||||
platten = kern_platten.bewertet(host_platten() + list(nas.get("platten") or []))
|
||||
return {"platten": platten, "nas": {"eingerichtet": nas["eingerichtet"], "ok": nas.get("ok", False),
|
||||
"detail": nas.get("detail"), "system": nas.get("system")}}
|
||||
|
||||
|
||||
def hinnehmen(pid: str) -> dict:
|
||||
return kern_platten.hinnehmen(pid, host_platten() + list(nas_snmp.stand().get("platten") or []))
|
||||
|
||||
|
||||
def pruefe_platten_gesundheit() -> list:
|
||||
"""Für den Wächter (Rolle homelab)."""
|
||||
from services.waechter import Befund, _aktion
|
||||
|
||||
befunde = []
|
||||
for p in lage()["platten"]:
|
||||
if p["stufe"] == "ok":
|
||||
continue
|
||||
fehler = any("→" in s for s in p["saetze"])
|
||||
befunde.append(Befund(
|
||||
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
|
||||
text="; ".join(p["saetze"]) + ". Die Daten darauf sichern und die Platte bald ersetzen, wenn es weiter "
|
||||
"schlechter wird.",
|
||||
quelle=p["id"].split(":")[0] if p["id"].startswith("nas:") else "pve",
|
||||
aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
|
||||
sofort=True))
|
||||
return befunde
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Plattengesundheit der KI-Box (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): smartctl (Paket smartmontools,
|
||||
seit 26.09.2026 auf der Box) je Platte aus lsblk, höchstens stündlich, bewertet mit kern/platten.py. smartctl braucht
|
||||
root (sudo -n); fehlt es oder verweigert sudo, gibt es schlicht keine Werte — nie einen Absturz (Windows, Tests).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern import platten as kern_platten
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CACHE_S = 3600
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"zeit": 0.0, "platten": None}
|
||||
|
||||
|
||||
def _laufen(befehl: list[str], zeitlimit: int = 30) -> tuple[int, str]:
|
||||
try:
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return 127, ""
|
||||
return r.returncode, r.stdout
|
||||
|
||||
|
||||
def geraete() -> list[str]:
|
||||
code, aus = _laufen(["lsblk", "-J", "-d", "-o", "NAME,TYPE"], 10)
|
||||
try:
|
||||
return [f"/dev/{d['name']}" for d in json.loads(aus).get("blockdevices") or [] if d.get("type") == "disk"
|
||||
and not str(d.get("name", "")).startswith(("zram", "loop"))] if code == 0 else []
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
|
||||
def lesen() -> list[dict]:
|
||||
if not shutil.which("sudo"): # Windows, Tests ohne sudo: keine Werte
|
||||
return []
|
||||
ergebnis = []
|
||||
for geraet in geraete():
|
||||
_, aus = _laufen(["sudo", "-n", "smartctl", "-j", "-a", geraet])
|
||||
try:
|
||||
daten = json.loads(aus)
|
||||
except ValueError:
|
||||
continue
|
||||
# smartctl setzt Bits im Exit-Code auch für „Fehler im Protokoll“ — gelesen ist gelesen.
|
||||
if not isinstance(daten, dict) or not (daten.get("model_name") or daten.get("nvme_smart_health_information_log")):
|
||||
continue
|
||||
ergebnis.append(kern_platten.aus_smartctl(daten, geraet, "KI-Box"))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def platten() -> list[dict]:
|
||||
with _lock:
|
||||
if _cache["platten"] is not None and time.time() - _cache["zeit"] < CACHE_S:
|
||||
return _cache["platten"]
|
||||
werte = lesen()
|
||||
with _lock:
|
||||
_cache.update(zeit=time.time(), platten=werte)
|
||||
return werte
|
||||
|
||||
|
||||
def lage() -> dict:
|
||||
"""Für GET /api/platten."""
|
||||
return {"platten": kern_platten.bewertet(platten())}
|
||||
|
||||
|
||||
def hinnehmen(pid: str) -> dict:
|
||||
return kern_platten.hinnehmen(pid, platten())
|
||||
|
||||
|
||||
def pruefe_platten_gesundheit() -> list:
|
||||
"""Für den Wächter (Rolle box)."""
|
||||
from services.waechter import Befund, _aktion
|
||||
|
||||
befunde = []
|
||||
for p in lage()["platten"]:
|
||||
if p["stufe"] == "ok":
|
||||
continue
|
||||
fehler = any("→" in s for s in p["saetze"])
|
||||
befunde.append(Befund(
|
||||
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
|
||||
text="; ".join(p["saetze"]) + ". Wichtiges sichern; wird es schlechter, die Platte ersetzen.",
|
||||
quelle="platte", aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
|
||||
sofort=True))
|
||||
return befunde
|
||||
@@ -640,6 +640,9 @@ PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_s
|
||||
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
|
||||
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
|
||||
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) if ROLLE == "homelab" else ()
|
||||
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
|
||||
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
|
||||
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
|
||||
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
|
||||
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
|
||||
if ROLLE == "homelab" else ()
|
||||
@@ -923,6 +926,12 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
if aktion_id == "hinnehmen": # Plattenfehler zur Kenntnis genommen (seit 26.09.2026)
|
||||
if ROLLE == "homelab":
|
||||
from services.homelab import platten as homelab_platten
|
||||
return homelab_platten.hinnehmen(str(aktion.get("platte") or ""))
|
||||
from services import platten
|
||||
return platten.hinnehmen(str(aktion.get("platte") or ""))
|
||||
if aktion_id == "bekannt": # neues Gerät im Heimnetz (seit 26.09.2026)
|
||||
from services.homelab import netzgeraete
|
||||
return netzgeraete.bestaetigen(str(aktion.get("mac") or ""))
|
||||
|
||||
@@ -97,6 +97,7 @@ def test_stand_von_anfang_an(daten, monkeypatch):
|
||||
"arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False,
|
||||
"echt_quelle": "standard"},
|
||||
"adguard": {"url": "http://192.168.178.100:8080", "zugang": "fehlt", "benutzer": None},
|
||||
"nas_snmp": {"zugang": "fehlt", "adresse": None},
|
||||
"karenz_h": 48,
|
||||
"ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False,
|
||||
"sicherung_speicher": None}}
|
||||
|
||||
@@ -222,7 +222,8 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
|
||||
""")
|
||||
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
|
||||
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
|
||||
"pruefe_zertifikate", "pruefe_docker"]
|
||||
"pruefe_zertifikate", "pruefe_docker", "pruefe_platten_gesundheit",
|
||||
"pruefe_netzgeraete"]
|
||||
|
||||
|
||||
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Plattengesundheit (Ausbauplan „Später“, Punkt 18, 26.09.2026): Proxmox-Host (pvesh), KI-Box (smartctl) und NAS
|
||||
(SNMP) in einer Form, gemeldet wird nur, was schlecht ist oder schlechter wird. Die Werte stammen von den echten
|
||||
Platten am 26.09. (PELADN 512GB im Host, YMTC PC41Q-2TB in der Box)."""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from kern import platten as kp
|
||||
from services.homelab import nas_snmp
|
||||
|
||||
NVME_TEXT = """
|
||||
SMART/Health Information (NVMe Log 0x02, NSID 0xffffffff)
|
||||
Critical Warning: 0x00
|
||||
Temperature: 41 Celsius
|
||||
Available Spare: 100%
|
||||
Available Spare Threshold: 1%
|
||||
Percentage Used: 2%
|
||||
Power On Hours: 9,992
|
||||
Unsafe Shutdowns: 6
|
||||
Media and Data Integrity Errors: 0
|
||||
Error Information Log Entries: 0
|
||||
"""
|
||||
HOST_DISK = {"devpath": "/dev/nvme0n1", "model": "PELADN 512GB", "serial": "2024110610000123", "type": "nvme",
|
||||
"size": 512110190592, "health": "PASSED", "wearout": 98}
|
||||
BOX_JSON = {"model_name": "YMTC PC41Q-2TB-B", "serial_number": "S1", "smart_status": {"passed": True},
|
||||
"user_capacity": {"bytes": 2048408248320}, "temperature": {"current": 35},
|
||||
"power_on_time": {"hours": 2380},
|
||||
"nvme_smart_health_information_log": {"critical_warning": 0, "temperature": 35, "available_spare": 100,
|
||||
"available_spare_threshold": 10, "percentage_used": 0,
|
||||
"media_errors": 0, "power_on_hours": 2380}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def test_host_platte_aus_proxmox():
|
||||
p = kp.aus_proxmox(HOST_DISK, {"health": "PASSED", "type": "text", "text": NVME_TEXT})
|
||||
assert (p["id"], p["name"], p["wo"], p["typ"]) == ("pve:2024110610000123", "PELADN 512GB", "Proxmox-Host /dev/nvme0n1",
|
||||
"nvme")
|
||||
assert (p["gesund"], p["verbraucht"], p["temperatur"], p["reserve"], p["reserve_grenze"], p["stunden"]) == (
|
||||
True, 2, 41, 100, 1, 9992)
|
||||
assert p["fehler"] == {"medien": 0} and kp.bewerten(p, None) == ("ok", [])
|
||||
|
||||
|
||||
def test_sata_attribute():
|
||||
werte = kp.aus_ata_attributen([{"id": 5, "raw": "8"}, {"id": 197, "raw": "0"}, {"id": 194, "raw": "36 (Min/Max 20/45)"},
|
||||
{"id": "x", "raw": "1"}])
|
||||
assert werte == {"fehler": {"umgelagert": 8, "ausstehend": 0}, "temperatur": 36}
|
||||
|
||||
|
||||
def test_box_platte_aus_smartctl():
|
||||
p = kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box")
|
||||
assert (p["id"], p["wo"], p["typ"], p["verbraucht"], p["temperatur"], p["stunden"]) == (
|
||||
"KI-Box:S1", "KI-Box /dev/nvme0n1", "nvme", 0, 35, 2380)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("aenderung, stufe, satz", [
|
||||
({"gesund": False}, "rot", "meldet sich selbst als defekt (SMART)"),
|
||||
({"warnung": 4}, "rot", "meldet eine kritische Warnung (0x04)"),
|
||||
({"reserve": 9, "reserve_grenze": 10}, "rot", "Ersatzblöcke fast aufgebraucht (9 %)"),
|
||||
({"verbraucht": 85}, "gelb", "zu 85 % verbraucht"),
|
||||
({"verbraucht": 93}, "rot", "zu 93 % verbraucht — Ersatz einplanen"),
|
||||
({"temperatur": 72}, "gelb", "72 °C warm (ab 70 °C zu heiß)"),
|
||||
({"fehler": {"medien": 3}}, "gelb", "Medienfehler: 0 → 3"),
|
||||
({"smart_warnung": True}, "gelb", "SMART meldet eine Warnung"),
|
||||
])
|
||||
def test_bewerten(aenderung, stufe, satz):
|
||||
p = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), **aenderung}
|
||||
assert kp.bewerten(p, {"medien": 0}) == (stufe, [satz])
|
||||
|
||||
|
||||
def test_nur_was_schlechter_wird(daten):
|
||||
alt = {**kp.aus_smartctl(BOX_JSON, "/dev/nvme0n1", "KI-Box"), "fehler": {"medien": 5}}
|
||||
assert kp.bewertet([alt])[0]["stufe"] == "ok" # beim ersten Blick schon da: Ausgangslage
|
||||
schlechter = {**alt, "fehler": {"medien": 7}}
|
||||
(p,) = kp.bewertet([schlechter])
|
||||
assert (p["stufe"], p["saetze"]) == ("gelb", ["Medienfehler: 5 → 7"])
|
||||
assert kp.befund_titel(p) == "Platte YMTC PC41Q-2TB-B (KI-Box /dev/nvme0n1): Medienfehler: 5 → 7"
|
||||
assert kp.hinnehmen(p["id"], [schlechter])["ok"]
|
||||
assert kp.bewertet([schlechter])[0]["stufe"] == "ok"
|
||||
assert not kp.hinnehmen("gibt-es-nicht", [schlechter])["ok"]
|
||||
|
||||
|
||||
# --- NAS über SNMP ----------------------------------------------------------------------------------------------
|
||||
|
||||
def _antwort_pdu(anfrage: bytes, zeilen: list[tuple[str, object]], community: str) -> bytes:
|
||||
"""Eine Response-PDU mit den Werten ab der erfragten OID (wie ein NAS, das GetBulk beantwortet)."""
|
||||
_, nachricht, _ = nas_snmp._lies(anfrage, 0)
|
||||
_, _, pos = nas_snmp._lies(nachricht, 0)
|
||||
_, gemeinschaft, pos = nas_snmp._lies(nachricht, pos)
|
||||
if gemeinschaft.decode() != community:
|
||||
return b"" # falscher Zugangsname: SNMP schweigt
|
||||
_, pdu, _ = nas_snmp._lies(nachricht, pos)
|
||||
_, nr, p = nas_snmp._lies(pdu, 0)
|
||||
for _ in range(2):
|
||||
_, _, p = nas_snmp._lies(pdu, p)
|
||||
_, bindungen, _ = nas_snmp._lies(pdu, p)
|
||||
_, bindung, _ = nas_snmp._lies(bindungen, 0)
|
||||
_, oid, _ = nas_snmp._lies(bindung, 0)
|
||||
ab = nas_snmp._oid_text(oid)
|
||||
|
||||
def schluessel(o: str) -> tuple:
|
||||
return tuple(int(t) for t in o.split("."))
|
||||
folgende = sorted((z for z in zeilen if schluessel(z[0]) > schluessel(ab)), key=lambda z: schluessel(z[0]))[:25]
|
||||
teile = b""
|
||||
for o, w in folgende:
|
||||
wert = nas_snmp._tlv(0x04, w.encode()) if isinstance(w, str) else nas_snmp._int(int(w))
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(o) + wert)
|
||||
if len(folgende) < 25:
|
||||
teile += nas_snmp._tlv(0x30, nas_snmp._oid(ab if not folgende else folgende[-1][0]) + b"\x82\x00")
|
||||
antwort = nas_snmp._tlv(0xA2, nas_snmp._tlv(0x02, nr) + nas_snmp._int(0) + nas_snmp._int(0)
|
||||
+ nas_snmp._tlv(0x30, teile))
|
||||
return nas_snmp._tlv(0x30, nas_snmp._int(1) + nas_snmp._tlv(0x04, gemeinschaft) + antwort)
|
||||
|
||||
|
||||
HD = nas_snmp.HD_TABELLE
|
||||
QNAP = [(f"{nas_snmp.SYS_DESCR}.0", "Linux TS-453D 5.10.60-qnap"),
|
||||
(f"{HD}.1.1", 1), (f"{HD}.1.2", 2), (f"{HD}.1.3", 3),
|
||||
(f"{HD}.2.1", "HDD1"), (f"{HD}.2.2", "HDD2"), (f"{HD}.2.3", "HDD3"),
|
||||
(f"{HD}.3.1", "34 C/93 F"), (f"{HD}.3.2", "36 C/96 F"), (f"{HD}.3.3", "--"),
|
||||
(f"{HD}.4.1", 0), (f"{HD}.4.2", 0), (f"{HD}.4.3", -5),
|
||||
(f"{HD}.5.1", "WDC WD40EFRX"), (f"{HD}.5.2", "ST4000VN008"), (f"{HD}.5.3", "--"),
|
||||
(f"{HD}.7.1", "GOOD"), (f"{HD}.7.2", "Abnormal"), (f"{HD}.7.3", "--")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nas(monkeypatch):
|
||||
"""Ein nachgestelltes NAS auf einem freien UDP-Port von 127.0.0.1."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.settimeout(0.2)
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
|
||||
def dienen():
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
anfrage, absender = sock.recvfrom(65535)
|
||||
except (TimeoutError, OSError):
|
||||
continue
|
||||
if antwort := _antwort_pdu(anfrage, QNAP, "geheim-lesen"):
|
||||
sock.sendto(antwort, absender)
|
||||
|
||||
faden = threading.Thread(target=dienen, daemon=True)
|
||||
faden.start()
|
||||
monkeypatch.setattr(nas_snmp, "PORT", sock.getsockname()[1])
|
||||
monkeypatch.setattr(nas_snmp, "ZEITLIMIT_S", 0.5)
|
||||
yield
|
||||
laeuft.clear()
|
||||
faden.join(timeout=2)
|
||||
sock.close()
|
||||
|
||||
|
||||
def test_nas_platten_ueber_snmp(nas):
|
||||
ergebnis = nas_snmp.lesen("127.0.0.1", "geheim-lesen")
|
||||
assert ergebnis["ok"] and ergebnis["system"] == "Linux TS-453D 5.10.60-qnap"
|
||||
platten = ergebnis["platten"]
|
||||
assert [(p["name"], p["wo"], p["gesund"], p["temperatur"]) for p in platten] == [
|
||||
("WDC WD40EFRX", "NAS HDD1", True, 34), ("ST4000VN008", "NAS HDD2", False, 36)] # leerer Schacht fällt weg
|
||||
assert kp.bewerten(platten[1], None)[0] == "rot"
|
||||
|
||||
|
||||
def test_nas_falscher_zugangsname_und_speichern(nas, daten, monkeypatch):
|
||||
monkeypatch.setattr(nas_snmp, "nas_adresse", lambda: "127.0.0.1")
|
||||
falsch = nas_snmp.setzen("public", "")
|
||||
assert not falsch["ok"] and "antwortet nicht über SNMP" in falsch["detail"] and "Nichts gespeichert" in falsch["detail"]
|
||||
assert nas_snmp.zugang() is None
|
||||
richtig = nas_snmp.setzen("geheim-lesen", "")
|
||||
assert richtig == {"ok": True, "text": "Das NAS antwortet (Linux TS-453D 5.10.60-qnap): 2 Platten, davon 1 gesund."}
|
||||
assert nas_snmp.zugang() == {"community": "geheim-lesen", "adresse": None}
|
||||
assert "geheim-lesen" not in str(nas_snmp.stand())
|
||||
assert nas_snmp.zugang_loeschen() and nas_snmp.stand() == {"eingerichtet": False, "ok": False, "platten": [],
|
||||
"detail": None}
|
||||
|
||||
|
||||
def test_disk_tabelle_der_neueren_firmware():
|
||||
d = nas_snmp.DISK_TABELLE
|
||||
werte = [(f"{d}.1.1", 1), (f"{d}.2.1", "Disk 1"), (f"{d}.5.1", "GOOD"), (f"{d}.6.1", 33),
|
||||
(f"{d}.8.1", "WDC WD40EFRX-68N32N0")]
|
||||
(p,) = nas_snmp.platten_aus_disk(werte)
|
||||
assert (p["name"], p["gesund"], p["temperatur"]) == ("WDC WD40EFRX-68N32N0", True, 33)
|
||||
Reference in New Issue
Block a user