# -*- coding: utf-8 -*- """ F1: System-Konfiguration — Hostname, User, Install-Pfad, OS-Info, Modell-Download. """ from __future__ import annotations import platform from textual.widgets import Checkbox, DataTable, Input, Label, RadioButton, RadioSet, Static from .base import InstallerView from ..system_info import ( SystemInfo, detect_system, format_system_summary, get_model_recommendations, ) class SystemView(InstallerView): """System-Einstellungen (F1).""" TITLE = "System" VIEW_ID = "system" MODES = ["server", "client", "standalone", "config"] def __init__(self, installer_config, **kwargs) -> None: super().__init__(installer_config, **kwargs) self._system_info: SystemInfo | None = None def compose(self): yield Static("── System-Konfiguration ──", classes="section-header") # OS-Info (nur Anzeige) os_info = f"{platform.system()} {platform.release()} ({platform.machine()})" py_info = platform.python_version() # Paketmanager erkennen import shutil pm_names = { "apt-get": "apt (Debian/Ubuntu/Raspbian)", "dnf": "dnf (Fedora/RHEL)", "pacman": "pacman (Arch Linux)", "zypper": "zypper (openSUSE)", } pm_info = "Nicht erkannt" for cmd, label in pm_names.items(): if shutil.which(cmd): pm_info = label break yield Label(f"Betriebssystem: {os_info}") yield Label(f"Python: {py_info}") yield Label(f"Paketmanager: {pm_info}") yield Label(f"Modus: {self._config.mode.title()}") yield Label("") # Hardware-Info (wird beim Aktivieren gefuellt) yield Static("── Hardware ──", classes="section-header") yield Label("Erkenne Hardware...", id="hw-info") yield Label("") # Hostname yield Static("── Konfiguration ──", classes="section-header") yield Label("Hostname:") yield Input( value=self._config.hostname, placeholder="z.B. trixyone", id="input-hostname", ) # Benutzername yield Label("Benutzername:") yield Input( value=self._config.username, placeholder="z.B. pi", id="input-username", ) # Install-Pfad yield Label("Installationspfad:") yield Input( value=self._config.install_path, placeholder="z.B. /home/pi/trixy", id="input-install-path", ) # Dienst-Optionen (nicht fuer config — config ist rein manuell) if self._config.mode != "config": yield Label("") yield Static("── Dienst ──", classes="section-header") yield Checkbox( "Beim Systemstart automatisch starten", value=self._config.autostart, id="cb-autostart", ) yield Checkbox( "Als systemd-Service einrichten", value=self._config.install_service, id="cb-install-service", disabled=not self._config.autostart, ) yield Label( " Service ermoeglicht: systemctl start/stop/status trixy-{mode}", id="lbl-service-hint", ) if self._config.mode in ("server", "standalone"): yield Checkbox( "Auto-Registrierung fuer Satellites (--auto-regist)", value=self._config.auto_regist, id="cb-auto-regist", ) yield Label( " Satellites werden automatisch akzeptiert ohne manuelle Bestaetigung.", ) else: # Config-Modus: Kein Autostart moeglich self._config.autostart = False self._config.install_service = False # NLP-Backend und Modelle (nur bei Modi mit ML) if self._config.mode in ("server", "standalone"): yield Label("") yield Static("── NLP Backend ──", classes="section-header") yield Label("Sprachverstaendnis (Intent-Erkennung):") yield RadioSet( RadioButton( "llama.cpp — Lokales LLM, kein Server noetig (empfohlen fuer Pi)", value=self._config.nlp_backend == "llama_cpp", id="rb-nlp-llama", ), RadioButton( "Ollama (lokal) — LLM-Server auf diesem Geraet", value=self._config.nlp_backend == "ollama", id="rb-nlp-ollama", ), RadioButton( "Ollama (extern) — LLM-Server auf anderem Geraet im Netzwerk", value=self._config.nlp_backend == "ollama_remote", id="rb-nlp-ollama-remote", ), id="rs-nlp-backend", ) yield Label("", id="lbl-nlp-hint") yield Label("Ollama Server-Adresse:") yield Input( value=self._config.ollama_host, placeholder="http://192.168.1.100:11434", id="input-ollama-host", disabled=self._config.nlp_backend not in ("ollama", "ollama_remote"), ) yield Label("") yield Static("── Modelle ──", classes="section-header") yield Checkbox( "ML-Modelle herunterladen (empfohlene Modelle)", value=self._config.download_models, id="cb-download-models", ) yield Label("") yield Static("── Modell-Empfehlungen ──", classes="section-header") yield Label("Hardware wird erkannt...", id="model-rec-hint") yield DataTable(id="model-rec-table") async def on_activate(self) -> None: """Erkennt Hardware und zeigt Empfehlungen.""" if self._system_info is not None: return self._system_info = detect_system() # Hardware-Info anzeigen hw_label = self.query_one("#hw-info", Label) hw_label.update(format_system_summary(self._system_info)) # Modell-Empfehlungen (nur server/standalone) if self._config.mode not in ("server", "standalone"): return recs = get_model_recommendations( self._system_info, self._config.selected_plugins, ) # Hinweistext hint = self.query_one("#model-rec-hint", Label) if not recs: hint.update("Keine Modelle fuer die ausgewaehlten Plugins verfuegbar.") return gpu_text = "" if self._system_info.gpu.cuda_available: gpu_text = f" | GPU: {self._system_info.gpu.name}" hint.update( f"Basierend auf: {self._system_info.ram_total_mb}MB RAM, " f"{self._system_info.disk_free_mb}MB Festplatte frei{gpu_text}" ) # Tabelle fuellen table = self.query_one("#model-rec-table", DataTable) table.clear(columns=True) table.add_columns("", "Modell", "Groesse", "Empfehlung") for rec in recs: status = "[+]" if rec.recommended else "[-]" size = f"{rec.size_mb} MB" table.add_row(status, rec.name, size, rec.reason) def on_radio_set_changed(self, event: RadioSet.Changed) -> None: """Aktualisiert NLP-Backend-Auswahl.""" if event.radio_set.id == "rs-nlp-backend": pressed_id = event.pressed.id if event.pressed else "" if pressed_id == "rb-nlp-llama": self._config.nlp_backend = "llama_cpp" elif pressed_id == "rb-nlp-ollama": self._config.nlp_backend = "ollama" self._config.ollama_host = "http://localhost:11434" elif pressed_id == "rb-nlp-ollama-remote": self._config.nlp_backend = "ollama_remote" # Host-Feld aktivieren/deaktivieren try: host_input = self.query_one("#input-ollama-host", Input) is_ollama = self._config.nlp_backend in ("ollama", "ollama_remote") host_input.disabled = not is_ollama if self._config.nlp_backend == "ollama": host_input.value = "http://localhost:11434" self._config.ollama_host = "http://localhost:11434" except Exception: pass # Hinweis aktualisieren try: hint = self.query_one("#lbl-nlp-hint", Label) if self._config.nlp_backend == "llama_cpp": hint.update( " llama-cpp-python wird installiert. " "Kompilierung kann auf ARM 10-30 Min dauern." ) elif self._config.nlp_backend == "ollama": hint.update( " Ollama muss auf diesem Geraet installiert sein: " "curl -fsSL https://ollama.com/install.sh | sh" ) elif self._config.nlp_backend == "ollama_remote": hint.update( " Ollama muss auf dem Zielgeraet laufen und im Netzwerk " "erreichbar sein (Port 11434)." ) except Exception: pass def on_checkbox_changed(self, event: Checkbox.Changed) -> None: """Aktualisiert Optionen.""" if event.checkbox.id == "cb-download-models": self._config.download_models = event.value elif event.checkbox.id == "cb-autostart": self._config.autostart = event.value # "Als Service" nur aktiv wenn Autostart an try: service_cb = self.query_one("#cb-install-service", Checkbox) service_cb.disabled = not event.value if not event.value: service_cb.value = False self._config.install_service = False except Exception: pass elif event.checkbox.id == "cb-install-service": self._config.install_service = event.value elif event.checkbox.id == "cb-auto-regist": self._config.auto_regist = event.value def on_input_changed(self, event: Input.Changed) -> None: """Aktualisiert Konfig bei Eingabeaenderungen.""" if event.input.id == "input-hostname": self._config.hostname = event.value elif event.input.id == "input-username": self._config.username = event.value # Install-Pfad automatisch anpassen self._config.install_path = f"/home/{event.value}/trixy" path_input = self.query_one("#input-install-path", Input) path_input.value = self._config.install_path elif event.input.id == "input-install-path": self._config.install_path = event.value elif event.input.id == "input-ollama-host": self._config.ollama_host = event.value @staticmethod def _check_hostname_conflict(hostname: str) -> str | None: """Prueft ob der Hostname bereits im Netzwerk existiert.""" import os import subprocess hostname = hostname.strip() # Eigener Hostname — kein Konflikt try: current = os.uname().nodename if current.lower() == hostname.lower(): return None except Exception: pass # Hostname im Netzwerk aufloesbbar? try: result = subprocess.run( ["getent", "hosts", hostname], capture_output=True, text=True, timeout=3, ) if result.returncode == 0 and result.stdout.strip(): ip = result.stdout.strip().split()[0] # Pruefen ob es unsere eigene IP ist own_ips = subprocess.run( ["hostname", "-I"], capture_output=True, text=True, timeout=3, ) own_ip_list = own_ips.stdout.strip().split() if own_ips.returncode == 0 else [] if ip not in own_ip_list: return ( f"Hostname '{hostname}' existiert bereits im Netzwerk " f"(IP: {ip}). Bitte anderen Hostnamen waehlen." ) except (FileNotFoundError, subprocess.TimeoutExpired): # getent nicht verfuegbar oder Timeout — ping als Fallback try: result = subprocess.run( ["ping", "-c", "1", "-W", "1", hostname], capture_output=True, timeout=3, ) if result.returncode == 0: return ( f"Hostname '{hostname}' antwortet bereits im Netzwerk. " f"Bitte anderen Hostnamen waehlen." ) except (FileNotFoundError, subprocess.TimeoutExpired): pass return None def validate(self) -> str | None: if not self._config.hostname.strip(): return "Hostname darf nicht leer sein" if not self._config.username.strip(): return "Benutzername darf nicht leer sein" if not self._config.install_path.strip(): return "Installationspfad darf nicht leer sein" # Hostname-Konflikt pruefen conflict = self._check_hostname_conflict(self._config.hostname) if conflict: return conflict return None