runner.py 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643
  1. # -*- coding: utf-8 -*-
  2. """
  3. InstallRunner — Fuehrt Installationsschritte aus.
  4. Steuert Progress, Logging und Abbruch.
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import os
  9. import shutil
  10. from typing import Callable
  11. from .config import InstallerConfig
  12. from .steps import InstallationStep, get_steps
  13. class InstallRunner:
  14. """
  15. Fuehrt Installationsschritte sequentiell aus.
  16. Bietet gewichtete Progress-Updates und Echtzeit-Log.
  17. """
  18. def __init__(
  19. self,
  20. config: InstallerConfig,
  21. log_callback: Callable[[str], None],
  22. progress_callback: Callable[[float, str], None],
  23. ) -> None:
  24. self._config = config
  25. self._log = log_callback
  26. self._progress = progress_callback
  27. self._cancelled = False
  28. self._current_process: asyncio.subprocess.Process | None = None
  29. # Wird von step_wifi_ap / step_wifi_connect gesetzt wenn
  30. # Netzwerk-Aenderungen geschrieben wurden die erst nach
  31. # Reboot aktiv werden. install_view liest das Flag am Ende
  32. # und zeigt eine prominente Warnung.
  33. self.reboot_required: bool = False
  34. self.reboot_reasons: list[str] = []
  35. self._swap_file: str = "" # Pfad zur angelegten Swap-Datei
  36. def cancel(self) -> None:
  37. """Bricht die Installation ab."""
  38. self._cancelled = True
  39. if self._current_process and self._current_process.returncode is None:
  40. self._current_process.terminate()
  41. async def run(self) -> bool:
  42. """
  43. Fuehrt alle Schritte aus.
  44. Returns:
  45. True bei Erfolg, False bei Fehler/Abbruch.
  46. """
  47. steps = get_steps(self._config.mode)
  48. total_weight = sum(s.weight for s in steps)
  49. completed_weight = 0
  50. self._log(f"=== Trixy {self._config.mode.title()} Installation ===")
  51. self._log(f"Version: {self._config.version}")
  52. self._log(f"Ziel: {self._config.install_path}")
  53. self._log("")
  54. # Sudo-Rechte vorab sicherstellen
  55. needs_sudo = any(
  56. s.command_fn in (
  57. "step_apt_packages", "step_hostname", "step_wifi_ap",
  58. "step_directories", "step_systemd",
  59. )
  60. for s in steps
  61. )
  62. if needs_sudo:
  63. self._log("Pruefe sudo-Rechte...")
  64. code, _ = await self._run_cmd("sudo", "-v", check=False)
  65. if code != 0:
  66. self._log(" FEHLER: sudo-Rechte benoetigt aber nicht verfuegbar.")
  67. self._log(" Bitte Installer mit 'sudo ./install-{mode}.sh' starten")
  68. self._log(" oder den Benutzer zur sudoers-Gruppe hinzufuegen.")
  69. return False
  70. self._log(" -> sudo verfuegbar\n")
  71. for i, step in enumerate(steps, 1):
  72. if self._cancelled:
  73. self._log("\n[ABBRUCH] Installation wurde abgebrochen.")
  74. return False
  75. self._log(f"[{i}/{len(steps)}] {step.name}...")
  76. self._progress(completed_weight / total_weight, step.name)
  77. method = getattr(self, step.command_fn, None)
  78. if method is None:
  79. self._log(f" WARNUNG: Methode {step.command_fn} nicht implementiert")
  80. completed_weight += step.weight
  81. continue
  82. try:
  83. success = await method()
  84. if not success:
  85. self._log(f" FEHLER bei Schritt: {step.name}")
  86. return False
  87. except Exception as e:
  88. self._log(f" FEHLER: {e}")
  89. return False
  90. completed_weight += step.weight
  91. self._progress(completed_weight / total_weight, step.name)
  92. self._log(f" -> {step.name} abgeschlossen\n")
  93. self._progress(1.0, "Fertig")
  94. self._log("=== Installation erfolgreich abgeschlossen ===")
  95. return True
  96. # --- Hilfsmethoden ---
  97. _ANSI_RE = None # Lazy-compiled Regex
  98. @staticmethod
  99. def _clean_ansi(text: str) -> str:
  100. """Entfernt ANSI Escape-Sequenzen aus Text."""
  101. import re
  102. if InstallRunner._ANSI_RE is None:
  103. InstallRunner._ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
  104. return InstallRunner._ANSI_RE.sub("", text)
  105. async def _run_cmd(self, *args: str, check: bool = True) -> tuple[int, str]:
  106. """Fuehrt einen Befehl aus und loggt die Ausgabe."""
  107. self._log(f" $ {' '.join(args)}")
  108. # TMPDIR auf Installationspfad umleiten (tmpfs auf Pi zu klein fuer pip)
  109. env = os.environ.copy()
  110. tmp_dir = f"{self._config.install_path}/tmp"
  111. os.makedirs(tmp_dir, exist_ok=True)
  112. env["TMPDIR"] = tmp_dir
  113. env["PIP_CACHE_DIR"] = f"{self._config.install_path}/pip-cache"
  114. proc = await asyncio.create_subprocess_exec(
  115. *args,
  116. stdout=asyncio.subprocess.PIPE,
  117. stderr=asyncio.subprocess.STDOUT,
  118. env=env,
  119. )
  120. self._current_process = proc
  121. output_lines: list[str] = []
  122. assert proc.stdout is not None
  123. while True:
  124. line = await proc.stdout.readline()
  125. if not line:
  126. break
  127. decoded = line.decode("utf-8", errors="replace").rstrip()
  128. # \r-basierte Progress-Zeilen: nur letzten Teil nehmen
  129. if "\r" in decoded:
  130. decoded = decoded.rsplit("\r", 1)[-1].strip()
  131. # ANSI-Codes entfernen
  132. decoded = self._clean_ansi(decoded)
  133. if not decoded:
  134. continue
  135. output_lines.append(decoded)
  136. self._log(f" {decoded}")
  137. if self._cancelled:
  138. proc.terminate()
  139. await proc.wait()
  140. return -1, "Abgebrochen"
  141. await proc.wait()
  142. self._current_process = None
  143. output = "\n".join(output_lines)
  144. if check and proc.returncode != 0:
  145. self._log(f" Befehl fehlgeschlagen (Exit-Code: {proc.returncode})")
  146. return proc.returncode or 0, output
  147. async def _run_sudo(self, *args: str, check: bool = True) -> tuple[int, str]:
  148. """Fuehrt einen Befehl mit sudo aus."""
  149. return await self._run_cmd("sudo", *args, check=check)
  150. # --- Installations-Schritte ---
  151. async def step_check_swap(self) -> bool:
  152. """Prueft RAM+Swap und legt bei Bedarf eine Swap-Datei an.
  153. Auf Raspberry Pi mit wenig RAM (1-2 GB) kann pip install
  154. oder Modell-Entpacken den OOM-Killer ausloesen. Eine
  155. temporaere Swap-Datei verhindert das.
  156. Logik:
  157. 1. Verfuegbaren RAM + bestehenden Swap ermitteln
  158. 2. Wenn Gesamt < MIN_TOTAL_MB → Swap-Datei anlegen
  159. 3. Swap-Datei wird in self._swap_file gespeichert
  160. 4. Am Ende der Installation:
  161. - Server/Standalone: Swap permanent behalten (fuer ML-Inferenz)
  162. - Client: Swap entfernen (leichtgewichtig)
  163. """
  164. MIN_TOTAL_MB = 3072 # 3 GB Minimum fuer sichere Installation
  165. SWAP_SIZE_MB = 2048 # 2 GB Swap-Datei
  166. self._log("Pruefe verfuegbaren Speicher...")
  167. # RAM ermitteln
  168. try:
  169. code, output = await self._run_cmd("free", "-m", check=False)
  170. ram_total = 0
  171. swap_total = 0
  172. for line in output.splitlines():
  173. parts = line.split()
  174. if not parts:
  175. continue
  176. if parts[0].lower().startswith("mem"):
  177. ram_total = int(parts[1])
  178. elif parts[0].lower().startswith("swap"):
  179. swap_total = int(parts[1])
  180. total = ram_total + swap_total
  181. self._log(f" RAM: {ram_total} MB, Swap: {swap_total} MB, Gesamt: {total} MB")
  182. if total >= MIN_TOTAL_MB:
  183. self._log(f" Speicher ausreichend (>= {MIN_TOTAL_MB} MB)")
  184. return True
  185. except Exception as e:
  186. self._log(f" WARNUNG: Speicher-Check fehlgeschlagen: {e}")
  187. self._log(f" Ueberspringe Swap-Setup")
  188. return True
  189. # Swap-Datei anlegen
  190. needed = MIN_TOTAL_MB - total
  191. swap_mb = max(SWAP_SIZE_MB, needed)
  192. swap_path = "/swapfile_trixy"
  193. self._log(f" Speicher zu gering ({total} MB < {MIN_TOTAL_MB} MB)")
  194. self._log(f" Lege {swap_mb} MB Swap-Datei an: {swap_path}")
  195. # Pruefen ob schon eine Trixy-Swap-Datei existiert
  196. code, _ = await self._run_cmd("test", "-f", swap_path, check=False)
  197. if code == 0:
  198. self._log(f" Swap-Datei existiert bereits — aktiviere sie")
  199. await self._run_sudo("swapon", swap_path, check=False)
  200. self._swap_file = swap_path
  201. return True
  202. # Pruefen ob genug Disk-Platz fuer Swap
  203. code, df_output = await self._run_cmd("df", "-m", "/", check=False)
  204. if code == 0:
  205. for line in df_output.splitlines()[1:]:
  206. parts = line.split()
  207. if len(parts) >= 4:
  208. avail_mb = int(parts[3])
  209. if avail_mb < swap_mb + 1024: # +1 GB Reserve
  210. self._log(
  211. f" WARNUNG: Nicht genug Platz fuer Swap "
  212. f"({avail_mb} MB frei, {swap_mb + 1024} MB benoetigt)"
  213. )
  214. self._log(f" Swap-Setup uebersprungen — Installation wird trotzdem versucht")
  215. return True
  216. break
  217. # Swap-Datei erstellen
  218. code, _ = await self._run_sudo(
  219. "fallocate", "-l", f"{swap_mb}M", swap_path, check=False,
  220. )
  221. if code != 0:
  222. # Fallback: dd (fuer Dateisysteme ohne fallocate)
  223. code, _ = await self._run_sudo(
  224. "dd", "if=/dev/zero", f"of={swap_path}",
  225. "bs=1M", f"count={swap_mb}",
  226. "status=progress", check=False,
  227. )
  228. if code != 0:
  229. self._log(f" WARNUNG: Swap-Datei konnte nicht erstellt werden")
  230. return True # Kein Abbruch — Installation trotzdem versuchen
  231. # Berechtigungen und Swap einrichten
  232. await self._run_sudo("chmod", "600", swap_path)
  233. code, _ = await self._run_sudo("mkswap", swap_path)
  234. if code != 0:
  235. self._log(f" WARNUNG: mkswap fehlgeschlagen")
  236. await self._run_sudo("rm", "-f", swap_path, check=False)
  237. return True
  238. code, _ = await self._run_sudo("swapon", swap_path)
  239. if code != 0:
  240. self._log(f" WARNUNG: swapon fehlgeschlagen")
  241. await self._run_sudo("rm", "-f", swap_path, check=False)
  242. return True
  243. self._swap_file = swap_path
  244. self._log(f" Swap-Datei aktiv: {swap_path} ({swap_mb} MB)")
  245. # Swappiness temporaer erhoehen damit Swap auch genutzt wird
  246. await self._run_sudo(
  247. "sysctl", "-w", "vm.swappiness=60", check=False,
  248. )
  249. return True
  250. async def step_cleanup_swap(self) -> bool:
  251. """Entfernt oder behält die Swap-Datei nach der Installation.
  252. - Server/Standalone: Swap permanent behalten (fstab Eintrag)
  253. → ML-Inferenz und Plugin-Ausfuehrung brauchen Speicher
  254. - Client: Swap entfernen (leichtgewichtig)
  255. """
  256. swap_path = getattr(self, "_swap_file", "")
  257. if not swap_path:
  258. self._log(" Keine Trixy-Swap-Datei vorhanden — nichts zu tun")
  259. return True
  260. if self._config.mode in ("server", "standalone"):
  261. # Swap permanent behalten
  262. self._log(f" Swap-Datei wird dauerhaft beibehalten: {swap_path}")
  263. # Pruefen ob schon in /etc/fstab
  264. code, fstab = await self._run_cmd("cat", "/etc/fstab", check=False)
  265. if swap_path not in fstab:
  266. # fstab-Eintrag hinzufuegen
  267. fstab_line = f"{swap_path} none swap sw 0 0"
  268. code, _ = await self._run_sudo(
  269. "bash", "-c",
  270. f"echo '{fstab_line}' >> /etc/fstab",
  271. )
  272. if code == 0:
  273. self._log(f" fstab-Eintrag hinzugefuegt")
  274. else:
  275. self._log(f" WARNUNG: fstab-Eintrag konnte nicht hinzugefuegt werden")
  276. else:
  277. self._log(f" fstab-Eintrag existiert bereits")
  278. else:
  279. # Client: Swap entfernen
  280. self._log(f" Client-Modus: Swap-Datei wird entfernt")
  281. await self._run_sudo("swapoff", swap_path, check=False)
  282. await self._run_sudo("rm", "-f", swap_path, check=False)
  283. self._log(f" Swap-Datei entfernt: {swap_path}")
  284. return True
  285. async def step_stop_service(self) -> bool:
  286. """Stoppt den laufenden Service vor dem Update (falls vorhanden).
  287. Verhindert dass eine laufende Python-Instanz Dateien blockiert oder
  288. mit alten Modulen weiterlaeuft nachdem der Code aktualisiert wurde.
  289. Wird uebersprungen wenn der Service nicht existiert (Erstinstallation).
  290. """
  291. service_name = f"trixy-{self._config.mode}.service"
  292. # Pruefen ob der Service ueberhaupt existiert
  293. code, _ = await self._run_cmd(
  294. "systemctl", "list-unit-files", service_name, check=False,
  295. )
  296. if code != 0:
  297. self._log(f" Service {service_name} nicht gefunden — ueberspringe Stop")
  298. return True
  299. # Pruefen ob der Service laeuft
  300. code, output = await self._run_cmd(
  301. "systemctl", "is-active", service_name, check=False,
  302. )
  303. if output.strip() != "active":
  304. self._log(f" Service {service_name} laeuft nicht — ueberspringe Stop")
  305. return True
  306. self._log(f" Stoppe {service_name}...")
  307. code, _ = await self._run_sudo(
  308. "systemctl", "stop", service_name, check=False,
  309. )
  310. if code == 0:
  311. self._log(f" -> {service_name} gestoppt")
  312. else:
  313. self._log(f" WARNUNG: Konnte {service_name} nicht stoppen")
  314. # Kurz warten bis alle Prozesse wirklich weg sind
  315. await asyncio.sleep(2)
  316. return True
  317. async def step_start_service(self) -> bool:
  318. """Startet den Service nach der Installation (wenn install_service + autostart)."""
  319. cfg = self._config
  320. if not cfg.install_service:
  321. return True
  322. service_name = f"trixy-{cfg.mode}.service"
  323. if not cfg.autostart:
  324. self._log(f" Autostart deaktiviert — {service_name} nicht automatisch gestartet")
  325. self._log(f" Manuell starten: sudo systemctl start {service_name}")
  326. return True
  327. self._log(f" Starte {service_name}...")
  328. code, _ = await self._run_sudo(
  329. "systemctl", "start", service_name, check=False,
  330. )
  331. if code == 0:
  332. self._log(f" -> {service_name} gestartet")
  333. # Kurz warten und Status pruefen
  334. await asyncio.sleep(2)
  335. _, status = await self._run_cmd(
  336. "systemctl", "is-active", service_name, check=False,
  337. )
  338. self._log(f" Status: {status.strip()}")
  339. else:
  340. self._log(f" WARNUNG: {service_name} konnte nicht gestartet werden")
  341. self._log(f" Logs pruefen: journalctl -u {service_name} -n 50")
  342. return True
  343. async def step_extract(self) -> bool:
  344. """Entpackt das Quellarchiv nach source/."""
  345. archive = self._config.source_archive
  346. source = self._config.source_path # install_path/source
  347. if not os.path.isfile(archive):
  348. self._log(f" Archiv nicht gefunden: {archive}")
  349. return False
  350. # source-Verzeichnis erstellen
  351. code, _ = await self._run_sudo("mkdir", "-p", source)
  352. if code != 0:
  353. return False
  354. # Besitzer setzen
  355. await self._run_sudo("chown", self._config.username, self._config.install_path)
  356. # Entpacken — strip-components=1 entfernt den Prefix (trixy-server-1.0.0/)
  357. self._log(f" Entpacke nach {source}...")
  358. code, _ = await self._run_cmd(
  359. "tar", "xzf", archive, "-C", source, "--strip-components=1",
  360. )
  361. return code == 0
  362. # Paketnamen pro Paketmanager
  363. # Schluessel = kanonischer Name, Werte = {pm: paketname}
  364. _PKG_MAP: dict[str, dict[str, str]] = {
  365. "python3": {"apt": "python3", "dnf": "python3", "pacman": "python", "zypper": "python3"},
  366. "python3-venv": {"apt": "python3-venv", "dnf": "python3-venv", "pacman": "python", "zypper": "python3-venv"},
  367. "python3-pip": {"apt": "python3-pip", "dnf": "python3-pip", "pacman": "python-pip", "zypper": "python3-pip"},
  368. "python3-dev": {"apt": "python3-dev", "dnf": "python3-devel", "pacman": "python", "zypper": "python3-devel"},
  369. "git": {"apt": "git", "dnf": "git", "pacman": "git", "zypper": "git"},
  370. "curl": {"apt": "curl", "dnf": "curl", "pacman": "curl", "zypper": "curl"},
  371. "wget": {"apt": "wget", "dnf": "wget", "pacman": "wget", "zypper": "wget"},
  372. "alsa-utils": {"apt": "alsa-utils", "dnf": "alsa-utils", "pacman": "alsa-utils", "zypper": "alsa-utils"},
  373. "ffmpeg": {"apt": "ffmpeg", "dnf": "ffmpeg-free", "pacman": "ffmpeg", "zypper": "ffmpeg"},
  374. "portaudio-dev": {"apt": "portaudio19-dev", "dnf": "portaudio-devel", "pacman": "portaudio", "zypper": "portaudio-devel"},
  375. "alsa-dev": {"apt": "libasound2-dev", "dnf": "alsa-lib-devel", "pacman": "alsa-lib", "zypper": "alsa-devel"},
  376. "openblas-dev": {"apt": "libopenblas-dev", "dnf": "openblas-devel", "pacman": "openblas", "zypper": "openblas-devel"},
  377. "lapack-dev": {"apt": "liblapack-dev", "dnf": "lapack-devel", "pacman": "lapack", "zypper": "lapack-devel"},
  378. "gfortran": {"apt": "gfortran", "dnf": "gcc-gfortran", "pacman": "gcc-fortran", "zypper": "gcc-fortran"},
  379. "ffi-dev": {"apt": "libffi-dev", "dnf": "libffi-devel", "pacman": "libffi", "zypper": "libffi-devel"},
  380. "ssl-dev": {"apt": "libssl-dev", "dnf": "openssl-devel", "pacman": "openssl", "zypper": "libopenssl-devel"},
  381. "hostapd": {"apt": "hostapd", "dnf": "hostapd", "pacman": "hostapd", "zypper": "hostapd"},
  382. "dnsmasq": {"apt": "dnsmasq", "dnf": "dnsmasq", "pacman": "dnsmasq", "zypper": "dnsmasq"},
  383. "iptables": {"apt": "iptables", "dnf": "iptables", "pacman": "iptables", "zypper": "iptables"},
  384. "netfilter-persistent": {"apt": "netfilter-persistent", "dnf": "", "pacman": "", "zypper": ""},
  385. "iptables-persistent": {"apt": "iptables-persistent", "dnf": "", "pacman": "", "zypper": ""},
  386. "cmake": {"apt": "cmake", "dnf": "cmake", "pacman": "cmake", "zypper": "cmake"},
  387. "g++": {"apt": "g++", "dnf": "gcc-c++", "pacman": "gcc", "zypper": "gcc-c++"},
  388. "evtest": {"apt": "evtest", "dnf": "evtest", "pacman": "evtest", "zypper": "evtest"},
  389. "input-utils": {"apt": "input-utils", "dnf": "input-utils", "pacman": "input-utils", "zypper": "input-utils"},
  390. }
  391. def _detect_pkg_manager(self) -> tuple[str, list[str], list[str], list[str]]:
  392. """
  393. Erkennt den Paketmanager.
  394. Returns:
  395. (name, update_cmd, install_cmd, check_cmd)
  396. install_cmd hat Platzhalter — Pakete werden angehaengt.
  397. """
  398. managers = [
  399. ("apt", ["apt-get", "update", "-qq"], ["apt-get", "install", "-y", "-qq"], ["dpkg", "-s"]),
  400. ("dnf", ["dnf", "check-update"], ["dnf", "install", "-y", "-q"], ["rpm", "-q"]),
  401. ("pacman", ["pacman", "-Sy"], ["pacman", "-S", "--noconfirm", "--needed"], ["pacman", "-Q"]),
  402. ("zypper", ["zypper", "refresh", "-q"], ["zypper", "install", "-y", "-q"], ["rpm", "-q"]),
  403. ]
  404. for name, update, install, check in managers:
  405. if shutil.which(install[0]):
  406. return name, update, install, check
  407. return "", [], [], []
  408. def _resolve_pkg(self, canonical: str, pm: str) -> str:
  409. """Loest einen kanonischen Paketnamen fuer den erkannten Paketmanager auf."""
  410. mapping = self._PKG_MAP.get(canonical, {})
  411. return mapping.get(pm, canonical)
  412. async def _install_pkg_group(
  413. self, name: str, canonical_names: list[str], required: bool = True,
  414. ) -> bool:
  415. """Installiert eine Paketgruppe mit dem erkannten Paketmanager."""
  416. pm_name, _, install_cmd, _ = self._detect_pkg_manager()
  417. if not pm_name:
  418. self._log(f" FEHLER: Kein unterstuetzter Paketmanager gefunden (apt/dnf/pacman/zypper)")
  419. return not required
  420. # Paketnamen aufloesen
  421. packages = []
  422. for canonical in canonical_names:
  423. resolved = self._resolve_pkg(canonical, pm_name)
  424. if resolved not in packages:
  425. packages.append(resolved)
  426. self._log(f" {name} ({pm_name}): {', '.join(packages)}")
  427. code, _ = await self._run_sudo(*install_cmd, *packages)
  428. if code != 0:
  429. # Einzeln versuchen
  430. self._log(f" Gruppeninstallation fehlgeschlagen — installiere einzeln...")
  431. failed = []
  432. for pkg in packages:
  433. c, _ = await self._run_sudo(*install_cmd, pkg)
  434. if c != 0:
  435. failed.append(pkg)
  436. if failed:
  437. self._log(f" WARNUNG: Nicht verfuegbar: {', '.join(failed)}")
  438. if required:
  439. return False
  440. return True
  441. async def step_apt_packages(self) -> bool:
  442. """Installiert System-Pakete in Gruppen."""
  443. pm_name, update_cmd, _, _ = self._detect_pkg_manager()
  444. if not pm_name:
  445. self._log(" FEHLER: Kein unterstuetzter Paketmanager gefunden")
  446. self._log(" Unterstuetzt: apt (Debian/Ubuntu), dnf (Fedora/RHEL),")
  447. self._log(" pacman (Arch), zypper (openSUSE)")
  448. self._log(" Bitte Pakete manuell installieren.")
  449. return False
  450. self._log(f" Paketmanager: {pm_name}")
  451. self._log(f" Aktualisiere Paketlisten...")
  452. # dnf check-update gibt Exit 100 bei verfuegbaren Updates — das ist OK
  453. code, _ = await self._run_sudo(*update_cmd, check=False)
  454. if code != 0 and pm_name != "dnf":
  455. self._log(f" WARNUNG: Paketlisten-Update fehlgeschlagen")
  456. # Runtime-Pakete (immer benoetigt)
  457. runtime = ["python3", "python3-venv", "python3-pip", "git", "curl", "wget"]
  458. if not await self._install_pkg_group("Runtime", runtime):
  459. return False
  460. # Audio-Runtime
  461. audio = ["alsa-utils", "ffmpeg"]
  462. if not await self._install_pkg_group("Audio", audio):
  463. return False
  464. # Build-Abhaengigkeiten (optional auf x86_64 mit Wheels)
  465. build = [
  466. "python3-dev", "portaudio-dev", "alsa-dev",
  467. "openblas-dev", "lapack-dev", "gfortran",
  468. "ffi-dev", "ssl-dev",
  469. ]
  470. await self._install_pkg_group("Build-Abhaengigkeiten", build, required=False)
  471. # HID / Input-Tools (Client/Standalone — Konferenzmikrofone, Headsets)
  472. if self._config.mode in ("client", "standalone"):
  473. hid = ["evtest", "input-utils"]
  474. await self._install_pkg_group("HID/Input", hid, required=False)
  475. # WiFi-Hotspot (nur Server)
  476. if self._config.mode == "server" and self._config.wifi_enabled:
  477. wifi = ["hostapd", "dnsmasq", "iptables"]
  478. if not await self._install_pkg_group("WiFi-Hotspot", wifi):
  479. return False
  480. # netfilter-persistent persistiert iptables-Regeln ueber
  481. # Reboots — optional, Fallback: Regeln werden beim Boot
  482. # erneut gesetzt oder per Firewall-Script angelegt.
  483. await self._install_pkg_group(
  484. "iptables-Persistenz",
  485. ["netfilter-persistent", "iptables-persistent"],
  486. required=False,
  487. )
  488. return True
  489. async def step_hostname(self) -> bool:
  490. """Setzt den Hostnamen."""
  491. code, _ = await self._run_sudo(
  492. "hostnamectl", "set-hostname", self._config.hostname,
  493. )
  494. return code == 0
  495. async def step_wifi_connect(self) -> bool:
  496. """Konfiguriert WLAN-Verbindung (Client/Standalone) — deferred.
  497. Legt NetworkManager-Profile an, verbindet aber NICHT sofort.
  498. Der Grund: wenn der User gerade ueber SSH auf dem aktuellen
  499. Netz installiert, wuerde ein `nmcli connection up` auf das neue
  500. Netz die Session killen.
  501. Stattdessen:
  502. - Primaeres Netz (Trixy-Hotspot) mit autoconnect-priority 100
  503. - Fallback-Netz (z.B. Heim-WLAN) mit autoconnect-priority 10
  504. - Auto-Switchback-Timer optional
  505. Beim naechsten Reboot entscheidet NetworkManager anhand der
  506. Prioritaeten: wenn das primaere Netz sichtbar ist, wird es
  507. bevorzugt — sonst bleibt der Pi auf dem Fallback.
  508. Setzt `self.reboot_required = True`.
  509. """
  510. if not self._config.wifi_connect:
  511. self._log(" Keine WLAN-Aenderung — uebersprungen")
  512. return True
  513. cfg = self._config
  514. primary_ssid = cfg.wifi_connect_ssid
  515. primary_pw = cfg.wifi_connect_password
  516. if not shutil.which("nmcli"):
  517. self._log(" FEHLER: nmcli nicht gefunden — NetworkManager erforderlich")
  518. self._log(" Hinweis: Raspberry Pi OS Bookworm nutzt NetworkManager als Standard")
  519. return False
  520. self._log(" Verwende NetworkManager (nmcli)...")
  521. # Primaeres Netz konfigurieren (Prioritaet 100)
  522. if not await self._nmcli_add_connection(
  523. ssid=primary_ssid, password=primary_pw, priority=100,
  524. ):
  525. self._log(f" FEHLER: Primaere Verbindung '{primary_ssid}' konnte nicht angelegt werden")
  526. return False
  527. self._log(f" Primaeres Netz angelegt: {primary_ssid} (Prioritaet 100)")
  528. # Fallback-Netz konfigurieren wenn aktiviert
  529. fallback_ssid = ""
  530. if cfg.wifi_fallback_enabled:
  531. fallback_ssid = cfg.wifi_fallback_ssid
  532. if await self._nmcli_add_connection(
  533. ssid=fallback_ssid, password=cfg.wifi_fallback_password, priority=10,
  534. ):
  535. self._log(f" Fallback-Netz angelegt: {fallback_ssid} (Prioritaet 10)")
  536. else:
  537. self._log(f" WARNUNG: Fallback-Verbindung '{fallback_ssid}' konnte nicht angelegt werden")
  538. fallback_ssid = ""
  539. # Failover-Script + Timer installieren (wenn Fallback + Auto-Switchback)
  540. if cfg.wifi_fallback_enabled and cfg.wifi_auto_switchback:
  541. if await self._install_wifi_failover_service(
  542. primary_ssid=primary_ssid,
  543. fallback_ssid=fallback_ssid or cfg.wifi_fallback_ssid,
  544. interval=cfg.wifi_switchback_interval,
  545. ):
  546. self._log(f" -> Auto-Rueckwechsel aktiviert (alle {cfg.wifi_switchback_interval}s)")
  547. else:
  548. self._log(" WARNUNG: Auto-Rueckwechsel konnte nicht installiert werden")
  549. # --- Reboot-Flag setzen ---
  550. # Keine sofortige Aktivierung — NetworkManager waehlt beim
  551. # naechsten Boot das Netz mit der hoechsten erreichbaren Prioritaet.
  552. self.reboot_required = True
  553. reason = f"Client verbindet sich nach Reboot primaer zu '{primary_ssid}'"
  554. if fallback_ssid:
  555. reason += f" (Fallback: '{fallback_ssid}')"
  556. self.reboot_reasons.append(reason)
  557. self._log("")
  558. self._log(" HINWEIS: WLAN-Profile sind angelegt aber NICHT aktiviert.")
  559. self._log(" NetworkManager verbindet beim naechsten Reboot.")
  560. self._log(" Die aktuelle SSH-Verbindung bleibt bestehen.")
  561. return True
  562. # --- nmcli Helper ---
  563. async def _nmcli_add_connection(
  564. self, ssid: str, password: str, priority: int,
  565. ) -> bool:
  566. """Legt eine NetworkManager WLAN-Verbindung an (oder aktualisiert sie)."""
  567. con_name = f"trixy-{ssid}"
  568. # Bestehende Verbindung mit gleichem Namen entfernen (idempotent)
  569. await self._run_sudo(
  570. "nmcli", "connection", "delete", con_name, check=False,
  571. )
  572. # Connection anlegen
  573. code, _ = await self._run_sudo(
  574. "nmcli", "connection", "add",
  575. "type", "wifi",
  576. "con-name", con_name,
  577. "ifname", "wlan0",
  578. "ssid", ssid,
  579. )
  580. if code != 0:
  581. return False
  582. # Passwort, Prioritaet, Auto-Connect konfigurieren
  583. code, _ = await self._run_sudo(
  584. "nmcli", "connection", "modify", con_name,
  585. "wifi-sec.key-mgmt", "wpa-psk",
  586. "wifi-sec.psk", password,
  587. "connection.autoconnect", "yes",
  588. "connection.autoconnect-priority", str(priority),
  589. "connection.autoconnect-retries", "0",
  590. )
  591. return code == 0
  592. async def _nmcli_try_connect(self, ssid: str, max_wait: int = 30) -> bool:
  593. """Versucht eine Connection zu aktivieren, mit Rescan-Retry.
  594. Wartet bis das Netz sichtbar ist und aktiviert die Verbindung.
  595. """
  596. con_name = f"trixy-{ssid}"
  597. waited = 0
  598. # Rescan erzwingen
  599. await self._run_sudo("nmcli", "device", "wifi", "rescan", check=False)
  600. while waited < max_wait:
  601. # Ist das Netz im Scan sichtbar?
  602. code, output = await self._run_cmd(
  603. "nmcli", "-t", "-f", "SSID", "device", "wifi", "list",
  604. check=False,
  605. )
  606. if code == 0 and ssid in output.splitlines():
  607. # Verbinden
  608. code, _ = await self._run_sudo(
  609. "nmcli", "connection", "up", con_name, check=False,
  610. )
  611. if code == 0:
  612. return True
  613. await asyncio.sleep(2)
  614. waited += 2
  615. await self._run_sudo("nmcli", "device", "wifi", "rescan", check=False)
  616. return False
  617. async def _install_wifi_failover_service(
  618. self, primary_ssid: str, fallback_ssid: str, interval: int,
  619. ) -> bool:
  620. """Installiert ein Failover-Script + systemd-Timer fuer Auto-Switchback.
  621. Prueft periodisch ob das primaere Netz wieder erreichbar ist, und
  622. wechselt dann von Fallback zurueck zum primaeren Netz.
  623. """
  624. primary_con = f"trixy-{primary_ssid}"
  625. fallback_con = f"trixy-{fallback_ssid}"
  626. # Shell-Escape fuer SSIDs (verhindert Injection)
  627. def sh_escape(s: str) -> str:
  628. return s.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
  629. script_content = f"""#!/usr/bin/env bash
  630. # ============================================================================
  631. # Trixy WLAN Auto-Switchback
  632. # Generiert vom Trixy-Installer — NICHT manuell editieren
  633. #
  634. # Prueft periodisch ob das primaere Netz erreichbar ist und wechselt zurueck
  635. # wenn der Client aktuell am Fallback-Netz haengt.
  636. # ============================================================================
  637. set -u
  638. PRIMARY_CON="{sh_escape(primary_con)}"
  639. PRIMARY_SSID="{sh_escape(primary_ssid)}"
  640. FALLBACK_CON="{sh_escape(fallback_con)}"
  641. FALLBACK_SSID="{sh_escape(fallback_ssid)}"
  642. # Aktuell aktive Verbindung auf wlan0 ermitteln
  643. current=$(nmcli -t -f NAME,DEVICE connection show --active \\
  644. | awk -F':' '$2 == "wlan0" {{ print $1; exit }}')
  645. # Bereits am primaeren Netz? -> fertig
  646. if [[ "${{current}}" == "${{PRIMARY_CON}}" ]]; then
  647. exit 0
  648. fi
  649. # Nur handeln wenn wir am Fallback haengen
  650. if [[ "${{current}}" != "${{FALLBACK_CON}}" ]]; then
  651. exit 0
  652. fi
  653. # Scan erzwingen
  654. nmcli device wifi rescan >/dev/null 2>&1 || true
  655. sleep 3
  656. # Ist das primaere Netz wieder sichtbar?
  657. if ! nmcli -t -f SSID device wifi list | grep -qxF "${{PRIMARY_SSID}}"; then
  658. exit 0
  659. fi
  660. # Wechsel versuchen
  661. logger -t trixy-wifi "Primaeres Netz ${{PRIMARY_SSID}} wieder sichtbar — wechsle zurueck"
  662. if nmcli connection up "${{PRIMARY_CON}}" >/dev/null 2>&1; then
  663. logger -t trixy-wifi "Wechsel erfolgreich"
  664. else
  665. logger -t trixy-wifi "Wechsel fehlgeschlagen — bleibe auf Fallback"
  666. fi
  667. """
  668. service_content = """[Unit]
  669. Description=Trixy WLAN Auto-Switchback
  670. After=NetworkManager.service
  671. Wants=NetworkManager.service
  672. [Service]
  673. Type=oneshot
  674. ExecStart=/usr/local/bin/trixy-wifi-switchback.sh
  675. """
  676. timer_content = f"""[Unit]
  677. Description=Trixy WLAN Auto-Switchback Timer
  678. [Timer]
  679. OnBootSec=2min
  680. OnUnitActiveSec={interval}s
  681. Unit=trixy-wifi-switchback.service
  682. [Install]
  683. WantedBy=timers.target
  684. """
  685. # Script schreiben
  686. if not await self._write_file_sudo(
  687. "/usr/local/bin/trixy-wifi-switchback.sh", script_content, mode="755",
  688. ):
  689. return False
  690. # Service schreiben
  691. if not await self._write_file_sudo(
  692. "/etc/systemd/system/trixy-wifi-switchback.service", service_content,
  693. ):
  694. return False
  695. # Timer schreiben
  696. if not await self._write_file_sudo(
  697. "/etc/systemd/system/trixy-wifi-switchback.timer", timer_content,
  698. ):
  699. return False
  700. # Systemd reload
  701. await self._run_sudo("systemctl", "daemon-reload", check=False)
  702. # Timer aktivieren und starten
  703. code, _ = await self._run_sudo(
  704. "systemctl", "enable", "--now", "trixy-wifi-switchback.timer",
  705. check=False,
  706. )
  707. return code == 0
  708. async def _write_file_sudo(
  709. self, path: str, content: str, mode: str = "644",
  710. ) -> bool:
  711. """Schreibt eine Datei mit sudo tee und setzt die Berechtigungen."""
  712. proc = await asyncio.create_subprocess_exec(
  713. "sudo", "tee", path,
  714. stdin=asyncio.subprocess.PIPE,
  715. stdout=asyncio.subprocess.DEVNULL,
  716. stderr=asyncio.subprocess.DEVNULL,
  717. )
  718. await proc.communicate(content.encode())
  719. if proc.returncode != 0:
  720. return False
  721. code, _ = await self._run_sudo("chmod", mode, path, check=False)
  722. return code == 0
  723. async def step_wifi_ap(self) -> bool:
  724. """Konfiguriert WiFi Access Point (Bookworm-kompatibel).
  725. Ablauf:
  726. 1. hostapd.conf schreiben (SSID, WPA2, Kanal)
  727. 2. dnsmasq.conf schreiben (DHCP-Range, DNS-Upstream)
  728. 3. systemd-networkd Profil fuer wlan0 statische IP
  729. (ersetzt den alten dhcpcd-Pfad — Bookworm hat kein dhcpcd)
  730. 4. NetworkManager Override: wlan0 wird "unmanaged", sonst
  731. klaut NM die Schnittstelle zurueck und ueberschreibt die IP
  732. 5. dnsmasq systemd-override mit ExecStartPre, der auf die
  733. statische wlan0-IP wartet (Race-Condition-Fix)
  734. 6. IP-Forwarding persistent + iptables MASQUERADE
  735. 7. Services nur `enable`, nicht `start` — die Umschaltung
  736. passiert bewusst erst beim naechsten Reboot, damit die
  737. aktuelle SSH-Session nicht stirbt.
  738. Setzt `self.reboot_required = True` wenn etwas geschrieben wurde.
  739. """
  740. if not self._config.wifi_enabled:
  741. self._log(" WiFi-Hotspot deaktiviert — uebersprungen")
  742. return True
  743. cfg = self._config
  744. # --- 1. hostapd.conf ---
  745. hostapd_conf = f"""# Trixy Access Point — erzeugt vom Installer
  746. interface=wlan0
  747. driver=nl80211
  748. ssid={cfg.wifi_ssid}
  749. hw_mode=g
  750. channel={cfg.wifi_channel}
  751. wmm_enabled=0
  752. macaddr_acl=0
  753. auth_algs=1
  754. ignore_broadcast_ssid=0
  755. wpa=2
  756. wpa_passphrase={cfg.wifi_password}
  757. wpa_key_mgmt=WPA-PSK
  758. wpa_pairwise=TKIP
  759. rsn_pairwise=CCMP
  760. country_code=DE
  761. ieee80211n=1
  762. ieee80211d=1
  763. """
  764. if not await self._write_file_sudo(
  765. "/etc/hostapd/hostapd.conf", hostapd_conf,
  766. ):
  767. return False
  768. # Default-Config-Pfad fuer hostapd setzen
  769. default_hostapd = 'DAEMON_CONF="/etc/hostapd/hostapd.conf"\n'
  770. await self._write_file_sudo("/etc/default/hostapd", default_hostapd)
  771. self._log(" -> /etc/hostapd/hostapd.conf geschrieben")
  772. # --- 2. dnsmasq.conf ---
  773. dnsmasq_conf = f"""# Trixy DHCP-Server — erzeugt vom Installer
  774. interface=wlan0
  775. bind-interfaces
  776. dhcp-range={cfg.dhcp_range_start},{cfg.dhcp_range_end},255.255.255.0,12h
  777. server=8.8.8.8
  778. server=8.8.4.4
  779. domain-needed
  780. bogus-priv
  781. address=/{cfg.wifi_ssid}/{cfg.ap_ip}
  782. address=/{cfg.hostname}/{cfg.ap_ip}
  783. """
  784. if not await self._write_file_sudo(
  785. "/etc/dnsmasq.conf", dnsmasq_conf,
  786. ):
  787. return False
  788. self._log(" -> /etc/dnsmasq.conf geschrieben")
  789. # --- 3. systemd-networkd Profil fuer wlan0 ---
  790. networkd_conf = f"""# Trixy — statische IP fuer wlan0 im AP-Modus
  791. [Match]
  792. Name=wlan0
  793. [Network]
  794. Address={cfg.ap_ip}/24
  795. DHCP=no
  796. IPForward=yes
  797. """
  798. await self._run_sudo("mkdir", "-p", "/etc/systemd/network")
  799. if not await self._write_file_sudo(
  800. "/etc/systemd/network/10-trixy-wlan0.network", networkd_conf,
  801. ):
  802. return False
  803. self._log(" -> /etc/systemd/network/10-trixy-wlan0.network geschrieben")
  804. # --- 4. NetworkManager: wlan0 unmanaged ---
  805. # Ohne diese Datei holt sich NetworkManager wlan0 nach jedem
  806. # Reboot zurueck und ueberschreibt die statische IP. `nmcli
  807. # device set wlan0 managed no` ist nur zur Laufzeit aktiv.
  808. nm_conf = """# Trixy — wlan0 aus NetworkManager-Kontrolle nehmen
  809. # Der Hotspot laeuft ueber systemd-networkd + hostapd.
  810. [keyfile]
  811. unmanaged-devices=interface-name:wlan0
  812. """
  813. await self._run_sudo("mkdir", "-p", "/etc/NetworkManager/conf.d")
  814. if not await self._write_file_sudo(
  815. "/etc/NetworkManager/conf.d/99-trixy-wlan0-unmanaged.conf", nm_conf,
  816. ):
  817. return False
  818. self._log(" -> /etc/NetworkManager/conf.d/99-trixy-wlan0-unmanaged.conf geschrieben")
  819. # --- 5. dnsmasq Race-Condition-Fix ---
  820. # dnsmasq darf erst starten wenn wlan0 tatsaechlich die statische
  821. # IP hat. Ohne ExecStartPre schlaegt dnsmasq "bind-interfaces" fehl.
  822. dnsmasq_override = f"""[Unit]
  823. After=systemd-networkd.service hostapd.service sys-subsystem-net-devices-wlan0.device
  824. Requires=sys-subsystem-net-devices-wlan0.device
  825. Wants=systemd-networkd.service hostapd.service
  826. [Service]
  827. ExecStartPre=/bin/sh -c 'for i in 1 2 3 4 5 6 7 8 9 10; do ip addr show wlan0 2>/dev/null | grep -q "inet {cfg.ap_ip}" && exit 0; sleep 1; done; exit 1'
  828. """
  829. await self._run_sudo("mkdir", "-p", "/etc/systemd/system/dnsmasq.service.d")
  830. if not await self._write_file_sudo(
  831. "/etc/systemd/system/dnsmasq.service.d/trixy-wait-wlan0.conf",
  832. dnsmasq_override,
  833. ):
  834. return False
  835. self._log(" -> dnsmasq Race-Condition-Fix aktiv")
  836. # --- 6. IP-Forwarding persistent + iptables ---
  837. sysctl_conf = "# Trixy — IP-Forwarding fuer AP-Modus\nnet.ipv4.ip_forward=1\n"
  838. await self._write_file_sudo(
  839. "/etc/sysctl.d/99-trixy-ipforward.conf", sysctl_conf,
  840. )
  841. # Zur Laufzeit sofort aktivieren — das stoert nix
  842. await self._run_sudo("sysctl", "-w", "net.ipv4.ip_forward=1", check=False)
  843. # NAT-Regel (wird nach Reboot von ifupdown-persistent / netfilter-persistent geladen)
  844. await self._run_sudo(
  845. "iptables", "-t", "nat", "-A", "POSTROUTING",
  846. "-o", "eth0", "-j", "MASQUERADE",
  847. check=False,
  848. )
  849. # iptables-Regeln persistieren wenn netfilter-persistent verfuegbar
  850. if shutil.which("netfilter-persistent"):
  851. await self._run_sudo("netfilter-persistent", "save", check=False)
  852. # --- 7. Services enable (NICHT start!) ---
  853. # Bewusst nur enable — der Pi bleibt auf seiner aktuellen
  854. # Netzwerk-Verbindung bis zum Reboot. Das schuetzt die SSH-Session
  855. # des Users.
  856. await self._run_sudo("systemctl", "unmask", "hostapd")
  857. await self._run_sudo("systemctl", "enable", "hostapd")
  858. await self._run_sudo("systemctl", "enable", "dnsmasq")
  859. await self._run_sudo("systemctl", "enable", "systemd-networkd")
  860. await self._run_sudo("systemctl", "daemon-reload")
  861. self._log(" -> hostapd, dnsmasq, systemd-networkd: enable (deferred)")
  862. # --- Reboot-Flag setzen ---
  863. self.reboot_required = True
  864. self.reboot_reasons.append(
  865. f"Server wird nach Reboot zum WLAN-Hotspot '{cfg.wifi_ssid}' "
  866. f"auf IP {cfg.ap_ip}"
  867. )
  868. self._log("")
  869. self._log(" HINWEIS: Netzwerk-Umschaltung erst nach Reboot aktiv.")
  870. self._log(" Die aktuelle SSH-Verbindung bleibt bestehen.")
  871. return True
  872. @staticmethod
  873. def _parse_requirements(path: str) -> list[str]:
  874. """
  875. Liest eine requirements.txt und gibt aktive Paketnamen zurueck.
  876. Ignoriert leere Zeilen, Kommentare (#) und Options-Zeilen (--, -r).
  877. """
  878. packages: list[str] = []
  879. try:
  880. with open(path) as f:
  881. for line in f:
  882. line = line.strip()
  883. # Leer, Kommentar, Option
  884. if not line or line.startswith("#") or line.startswith("-"):
  885. continue
  886. # Inline-Kommentar entfernen: "numpy # Array-Verarbeitung"
  887. if " #" in line:
  888. line = line.split(" #")[0].strip()
  889. if line:
  890. packages.append(line)
  891. except OSError:
  892. pass
  893. return packages
  894. async def _pip_install_packages(
  895. self, pip: str, packages: list[str], label: str = "",
  896. ) -> tuple[int, int]:
  897. """
  898. Installiert Pakete einzeln via pip.
  899. Jedes Paket wird separat installiert — so bleibt /tmp sauber
  900. und der Fortschritt ist sichtbar.
  901. Args:
  902. pip: Pfad zu pip im venv
  903. packages: Liste der zu installierenden Pakete
  904. label: Kontext-Label fuer Log (z.B. "Kern" oder Plugin-Name)
  905. Returns:
  906. (erfolgreich, fehlgeschlagen)
  907. """
  908. success = 0
  909. failed = 0
  910. total = len(packages)
  911. for i, pkg in enumerate(packages, 1):
  912. if self._cancelled:
  913. return success, failed
  914. prefix = f" [{i}/{total}]" if total > 1 else " "
  915. self._log(f"{prefix} {pkg}...")
  916. code, _ = await self._run_cmd(pip, "install", pkg)
  917. if code == 0:
  918. success += 1
  919. else:
  920. failed += 1
  921. self._log(f" WARNUNG: {pkg} fehlgeschlagen")
  922. return success, failed
  923. async def step_venv(self) -> bool:
  924. """Erstellt Python venv und installiert Requirements einzeln."""
  925. venv = self._config.venv_path
  926. source = self._config.source_path
  927. # venv erstellen
  928. self._log(" Erstelle Python venv...")
  929. code, _ = await self._run_cmd("python3", "-m", "venv", venv)
  930. if code != 0:
  931. return False
  932. # pip aktualisieren
  933. pip = f"{venv}/bin/pip"
  934. self._log(" Aktualisiere pip...")
  935. code, _ = await self._run_cmd(pip, "install", "--upgrade", "pip", "setuptools", "wheel")
  936. if code != 0:
  937. return False
  938. # Kern-Requirements einzeln installieren
  939. req_file = f"{source}/requirements.txt"
  940. packages = self._parse_requirements(req_file)
  941. if not packages:
  942. self._log(" Keine Requirements gefunden")
  943. return True
  944. self._log(f" Installiere {len(packages)} Kern-Pakete...\n")
  945. ok, fail = await self._pip_install_packages(pip, packages, "Kern")
  946. self._log(f"\n Ergebnis: {ok} installiert, {fail} fehlgeschlagen")
  947. if fail > 0:
  948. self._log(" WARNUNG: Einige Pakete konnten nicht installiert werden")
  949. # Nicht abbrechen — manche Pakete sind optional (z.B. kenlm)
  950. return True
  951. # Zusaetzliche pip-Pakete pro Plugin die nicht in requirements.txt stehen
  952. # (weil sie auskommentiert sind oder backend-abhaengig)
  953. _PLUGIN_EXTRA_DEPS: dict[str, list[str]] = {
  954. "stt_vosk": ["vosk"],
  955. "stt_whisper": ["openai-whisper"],
  956. "tts_coqui": ["TTS"],
  957. "tts_piper": ["piper-tts"],
  958. "tts_google": ["google-cloud-texttospeech"],
  959. "nlp_classifier": ["sentence-transformers", "onnxruntime", "transformers"],
  960. }
  961. # apt/system-Pakete die vor pip install noetig sind (fuer Kompilierung)
  962. _PLUGIN_SYSTEM_DEPS: dict[str, list[str]] = {
  963. "nlp_llm": ["cmake"], # llama-cpp-python braucht cmake zum Bauen
  964. }
  965. async def step_plugin_deps(self) -> bool:
  966. """Installiert Plugin-Abhaengigkeiten fuer ALLE vorhandenen Plugins.
  967. Durchsucht das plugins/ Verzeichnis rekursiv nach requirements.txt
  968. Dateien und installiert die darin enthaltenen Pakete.
  969. Zusaetzliche Backend-spezifische Pakete werden aus _PLUGIN_EXTRA_DEPS
  970. ergaenzt. Plugins ohne requirements.txt werden uebersprungen.
  971. """
  972. import os
  973. pip = f"{self._config.venv_path}/bin/pip"
  974. source = self._config.source_path
  975. cfg = self._config
  976. plugins_dir = f"{source}/plugins"
  977. total_ok = 0
  978. total_fail = 0
  979. if not os.path.isdir(plugins_dir):
  980. self._log(f" Kein plugins/ Verzeichnis gefunden: {plugins_dir}")
  981. return True
  982. # Alle Plugin-Ordner ermitteln (direkte Unterordner)
  983. try:
  984. plugin_names = sorted(
  985. name for name in os.listdir(plugins_dir)
  986. if os.path.isdir(os.path.join(plugins_dir, name))
  987. and not name.startswith((".", "_"))
  988. )
  989. except OSError as e:
  990. self._log(f" FEHLER beim Lesen des Plugin-Verzeichnisses: {e}")
  991. return False
  992. self._log(f" Gefunden: {len(plugin_names)} Plugins in {plugins_dir}")
  993. for plugin in plugin_names:
  994. if self._cancelled:
  995. return False
  996. # 1. requirements.txt parsen
  997. req_file = f"{plugins_dir}/{plugin}/requirements.txt"
  998. packages = self._parse_requirements(req_file)
  999. # 2. Zusaetzliche Pakete (Backend-spezifisch) hinzufuegen
  1000. extras = self._PLUGIN_EXTRA_DEPS.get(plugin, [])
  1001. packages.extend(extras)
  1002. if not packages:
  1003. continue # Plugin hat keine Abhaengigkeiten
  1004. self._log(f"\n Plugin: {plugin} ({len(packages)} Pakete)")
  1005. ok, fail = await self._pip_install_packages(pip, packages, plugin)
  1006. total_ok += ok
  1007. total_fail += fail
  1008. # 3. NLP Backend — spezielle Behandlung (nur wenn nlp_llm vorhanden)
  1009. if "nlp_llm" in plugin_names:
  1010. await self._install_nlp_backend()
  1011. if total_ok > 0 or total_fail > 0:
  1012. self._log(f"\n Plugin-Pakete: {total_ok} installiert, {total_fail} fehlgeschlagen")
  1013. return True
  1014. async def _install_nlp_backend(self) -> None:
  1015. """Installiert das gewaehlte NLP-Backend."""
  1016. pip = f"{self._config.venv_path}/bin/pip"
  1017. source = self._config.source_path
  1018. backend = self._config.nlp_backend
  1019. self._log(f" NLP Backend: {backend}")
  1020. if backend == "llama_cpp":
  1021. # cmake als System-Dep (fuer C++ Kompilierung)
  1022. pm_name, _, install_cmd, _ = self._detect_pkg_manager()
  1023. if pm_name:
  1024. cmake_pkg = self._resolve_pkg("cmake", pm_name) if "cmake" in self._PKG_MAP else "cmake"
  1025. self._log(f" Installiere cmake (Build-Abhaengigkeit)...")
  1026. await self._run_sudo(*install_cmd, cmake_pkg, check=False)
  1027. # llama-cpp-python installieren (kann auf ARM lange dauern)
  1028. self._log(f" Installiere llama-cpp-python (kann 10-30 Min dauern auf ARM)...")
  1029. code, _ = await self._run_cmd(
  1030. pip, "install", "llama-cpp-python",
  1031. )
  1032. if code != 0:
  1033. self._log(f" FEHLER: llama-cpp-python Installation fehlgeschlagen")
  1034. self._log(f" Alternativ: Backend auf 'ollama' umstellen")
  1035. else:
  1036. self._log(f" -> llama-cpp-python installiert")
  1037. elif backend in ("ollama", "ollama_remote"):
  1038. # aiohttp fuer Ollama REST-API
  1039. self._log(f" Installiere aiohttp (Ollama Client)...")
  1040. code, _ = await self._run_cmd(pip, "install", "aiohttp")
  1041. if code != 0:
  1042. self._log(f" WARNUNG: aiohttp fehlgeschlagen")
  1043. if backend == "ollama":
  1044. # Lokales Ollama — pruefen ob installiert
  1045. if not shutil.which("ollama"):
  1046. self._log(f" WARNUNG: Ollama nicht gefunden!")
  1047. self._log(f" Installieren mit: curl -fsSL https://ollama.com/install.sh | sh")
  1048. self._log(f" Danach: ollama pull qwen2.5:1.5b")
  1049. else:
  1050. self._log(f" -> Ollama gefunden: {shutil.which('ollama')}")
  1051. else:
  1052. # Externer Ollama-Server
  1053. host = self._config.ollama_host
  1054. self._log(f" Externer Ollama-Server: {host}")
  1055. self._log(f" Stelle sicher dass der Server erreichbar ist und")
  1056. self._log(f" das Modell geladen wurde (ollama pull qwen2.5:1.5b)")
  1057. # 4. Config anpassen
  1058. # ollama_remote wird intern als "ollama" gespeichert (selbes Backend, anderer Host)
  1059. config_backend = "ollama" if backend == "ollama_remote" else backend
  1060. config_file = f"{source}/plugins/nlp_llm/config.json"
  1061. if os.path.isfile(config_file):
  1062. try:
  1063. import json
  1064. with open(config_file) as f:
  1065. nlp_cfg = json.load(f)
  1066. nlp_cfg["backend"] = config_backend
  1067. nlp_cfg["ollama_host"] = self._config.ollama_host
  1068. if config_backend == "ollama":
  1069. nlp_cfg["model_name"] = nlp_cfg.get("model_name", "qwen2.5:1.5b")
  1070. with open(config_file, "w") as f:
  1071. json.dump(nlp_cfg, f, indent=4, ensure_ascii=False)
  1072. self._log(f" -> config.json: backend={config_backend}, host={self._config.ollama_host}")
  1073. except Exception as e:
  1074. self._log(f" WARNUNG: Config-Update fehlgeschlagen: {e}")
  1075. async def step_directories(self) -> bool:
  1076. """Erstellt Verzeichnisse und setzt Berechtigungen."""
  1077. source = self._config.source_path
  1078. mode = self._config.mode
  1079. # Basis-Verzeichnisse (alle Modi)
  1080. dirs = [
  1081. "data",
  1082. "logs",
  1083. "cache",
  1084. "certs",
  1085. ]
  1086. # Modell-Verzeichnisse — exakte Struktur, verhindert Tippfehler
  1087. model_dirs = [
  1088. "models",
  1089. "models/wakeword",
  1090. "models/wakeword/trixy",
  1091. "models/wakeword/system_command",
  1092. "models/wakeword/alexa",
  1093. "models/wakeword/hey_jarvis",
  1094. "models/wakeword/hey_mycroft",
  1095. "models/piper",
  1096. "models/nlp",
  1097. "models/symspell",
  1098. ]
  1099. # Server/Standalone-spezifisch
  1100. if mode in ("server", "standalone"):
  1101. dirs.extend([
  1102. "satellites",
  1103. "sync_data",
  1104. "assets",
  1105. "assets/default",
  1106. ])
  1107. # Plugin-Modell-Verzeichnisse
  1108. for plugin in self._config.selected_plugins:
  1109. dirs.append(f"plugins/{plugin}/models")
  1110. # Client-spezifisch
  1111. if mode == "client":
  1112. dirs.extend([
  1113. "assets",
  1114. ])
  1115. # Trainer-Verzeichnisse (Server/Standalone)
  1116. if mode in ("server", "standalone"):
  1117. dirs.extend([
  1118. "trainer/data/raw",
  1119. "trainer/data/processed",
  1120. "trainer/cache",
  1121. "trainer/assets",
  1122. ])
  1123. all_dirs = dirs + model_dirs
  1124. self._log(f" Erstelle {len(all_dirs)} Verzeichnisse...")
  1125. for d in all_dirs:
  1126. path = f"{source}/{d}"
  1127. await self._run_sudo("mkdir", "-p", path)
  1128. # Shell-Scripte ausfuehrbar machen
  1129. scripts = [
  1130. "run_linux.sh",
  1131. "install_linux.sh",
  1132. "install_requirements.sh",
  1133. "bash/update_linux.sh",
  1134. "bash/prepare_linux.sh",
  1135. ]
  1136. made_exec = 0
  1137. for script in scripts:
  1138. path = f"{source}/{script}"
  1139. if os.path.isfile(path):
  1140. await self._run_cmd("chmod", "+x", path)
  1141. made_exec += 1
  1142. # User zur 'input'-Gruppe hinzufuegen (fuer HID Media Keys / /dev/input/event*)
  1143. if mode in ("client", "standalone"):
  1144. code, _ = await self._run_sudo(
  1145. "usermod", "-aG", "input", self._config.username, check=False,
  1146. )
  1147. if code == 0:
  1148. self._log(f" -> User '{self._config.username}' zur Gruppe 'input' hinzugefuegt (HID)")
  1149. # Besitzer setzen
  1150. await self._run_sudo(
  1151. "chown", "-R",
  1152. f"{self._config.username}:{self._config.username}",
  1153. self._config.install_path,
  1154. )
  1155. self._log(f" -> {len(all_dirs)} Verzeichnisse erstellt, {made_exec} Scripte ausfuehrbar")
  1156. return True
  1157. async def step_systemd(self) -> bool:
  1158. """Erstellt den systemd-Service und aktiviert ihn bei Bedarf."""
  1159. cfg = self._config
  1160. if not cfg.install_service:
  1161. self._log(" Service-Installation deaktiviert — uebersprungen")
  1162. return True
  1163. service_name = f"trixy-{cfg.mode}"
  1164. # ExecStart zusammenbauen
  1165. # run_linux.sh fuehrt Update + Bereinigung + Start durch
  1166. run_script = f"{cfg.source_path}/run_linux.sh"
  1167. exec_args = ["/bin/bash", run_script]
  1168. if cfg.mode == "client":
  1169. exec_args.extend([
  1170. "client",
  1171. "--host", cfg.server_host,
  1172. "--port", str(cfg.server_port),
  1173. "--room", cfg.room,
  1174. "--alias", cfg.alias,
  1175. ])
  1176. else:
  1177. exec_args.append(cfg.mode)
  1178. if cfg.auto_regist and cfg.mode in ("server", "standalone"):
  1179. exec_args.append("--auto-regist")
  1180. exec_args.append("--debug")
  1181. exec_start = " ".join(exec_args)
  1182. # After-Deps fuer Server mit WiFi-AP
  1183. after_deps = "network-online.target"
  1184. if cfg.mode == "server" and cfg.wifi_enabled:
  1185. after_deps = "network-online.target hostapd.service dnsmasq.service"
  1186. service_content = f"""[Unit]
  1187. Description=Trixy Voice Assistant ({cfg.mode.title()})
  1188. After={after_deps}
  1189. Wants=network-online.target
  1190. [Service]
  1191. Type=simple
  1192. User={cfg.username}
  1193. Group={cfg.username}
  1194. WorkingDirectory={cfg.source_path}
  1195. ExecStart={exec_start}
  1196. Restart=on-failure
  1197. RestartSec=5
  1198. StartLimitIntervalSec=60
  1199. StartLimitBurst=5
  1200. Environment=PYTHONUNBUFFERED=1
  1201. Environment=HOME=/home/{cfg.username}
  1202. StandardOutput=journal
  1203. StandardError=journal
  1204. SyslogIdentifier=trixy
  1205. [Install]
  1206. WantedBy=multi-user.target
  1207. """
  1208. # Service-Datei schreiben
  1209. proc = await asyncio.create_subprocess_exec(
  1210. "sudo", "tee", f"/etc/systemd/system/{service_name}.service",
  1211. stdin=asyncio.subprocess.PIPE,
  1212. stdout=asyncio.subprocess.DEVNULL,
  1213. )
  1214. await proc.communicate(service_content.encode())
  1215. await self._run_sudo("systemctl", "daemon-reload")
  1216. # Autostart je nach Einstellung
  1217. if cfg.autostart:
  1218. await self._run_sudo("systemctl", "enable", f"{service_name}.service")
  1219. self._log(f" -> {service_name}.service aktiviert (Autostart)")
  1220. else:
  1221. await self._run_sudo("systemctl", "disable", f"{service_name}.service")
  1222. self._log(f" -> {service_name}.service erstellt (kein Autostart)")
  1223. self._log(f" Manuell starten: sudo systemctl start {service_name}")
  1224. return True
  1225. def _get_model_downloads(self) -> list[tuple[str, str, str, bool, str | None]]:
  1226. """
  1227. Gibt die Download-Liste basierend auf Hardware-Empfehlung zurueck.
  1228. Returns:
  1229. Liste von (name, url, ziel_relativ_zu_source, entpacken?, plugin_name).
  1230. plugin_name ist None fuer Core-Modelle.
  1231. """
  1232. from .system_info import detect_system, get_model_recommendations
  1233. info = detect_system()
  1234. recs = get_model_recommendations(info, self._config.selected_plugins)
  1235. downloads: list[tuple[str, str, str, bool, str | None]] = []
  1236. # Empfohlene Modelle ermitteln
  1237. rec_names = {r.name for r in recs if r.recommended}
  1238. self._log(f" Hardware: {info.ram_total_mb}MB RAM, {info.disk_free_mb}MB frei")
  1239. if info.gpu.cuda_available:
  1240. self._log(f" GPU: {info.gpu.name} ({info.gpu.vram_mb}MB VRAM)")
  1241. else:
  1242. self._log(" GPU: Keine CUDA-GPU erkannt")
  1243. self._log(f" {len(rec_names)} empfohlene Modelle erkannt")
  1244. self._log("")
  1245. # --- STT Vosk ---
  1246. if "stt_vosk" in self._config.selected_plugins:
  1247. if "Vosk STT (Deutsch, gross)" in rec_names:
  1248. downloads.append((
  1249. "Vosk STT (Deutsch, gross)",
  1250. "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip",
  1251. "plugins/stt_vosk/models",
  1252. True, "stt_vosk",
  1253. ))
  1254. elif "Vosk STT (Deutsch, klein)" in rec_names:
  1255. downloads.append((
  1256. "Vosk STT (Deutsch, klein)",
  1257. "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip",
  1258. "plugins/stt_vosk/models",
  1259. True, "stt_vosk",
  1260. ))
  1261. # --- STT Whisper ---
  1262. if "stt_whisper" in self._config.selected_plugins:
  1263. # Bestes empfohlenes Whisper-Modell waehlen
  1264. whisper_priority = ["medium", "small", "base", "tiny"]
  1265. for model_name in whisper_priority:
  1266. full_name = f"Whisper STT ({model_name})"
  1267. if full_name in rec_names:
  1268. self._log(f" Whisper: {model_name} empfohlen (wird beim Start geladen)")
  1269. break
  1270. # --- TTS Piper ---
  1271. if "tts_piper" in self._config.selected_plugins:
  1272. if "Piper TTS (thorsten-medium)" in rec_names:
  1273. downloads.append((
  1274. "Piper TTS (thorsten-medium, onnx)",
  1275. "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
  1276. "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx",
  1277. "models/piper/de_DE-thorsten-medium.onnx",
  1278. False, None,
  1279. ))
  1280. downloads.append((
  1281. "Piper TTS (thorsten-medium, config)",
  1282. "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
  1283. "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json",
  1284. "models/piper/de_DE-thorsten-medium.onnx.json",
  1285. False, None,
  1286. ))
  1287. # --- NLP LLM ---
  1288. if "nlp_llm" in self._config.selected_plugins:
  1289. if "Qwen 2.5 1.5B (Q4_K_M)" in rec_names:
  1290. downloads.append((
  1291. "Qwen 2.5 1.5B (Q4_K_M)",
  1292. "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/"
  1293. "qwen2.5-1.5b-instruct-q4_k_m.gguf",
  1294. "models/nlp/qwen2.5-1.5b-instruct-q4_k_m.gguf",
  1295. False, None,
  1296. ))
  1297. return downloads
  1298. async def step_download_models(self) -> bool:
  1299. """Laedt ML-Modelle basierend auf Hardware-Empfehlung herunter."""
  1300. if not self._config.download_models:
  1301. self._log(" Modell-Download deaktiviert — uebersprungen")
  1302. return True
  1303. source = self._config.source_path
  1304. downloads = self._get_model_downloads()
  1305. if not downloads:
  1306. self._log(" Keine Modelle zum Herunterladen (keine passenden Empfehlungen)")
  1307. return True
  1308. downloaded = 0
  1309. total_size = 0
  1310. for name, url, target_rel, extract, plugin in downloads:
  1311. if self._cancelled:
  1312. return False
  1313. target = f"{source}/{target_rel}"
  1314. # Pruefen ob bereits vorhanden
  1315. if not extract and os.path.isfile(target):
  1316. self._log(f" {name} — bereits vorhanden")
  1317. continue
  1318. if extract and os.path.isdir(target) and os.listdir(target):
  1319. self._log(f" {name} — bereits vorhanden")
  1320. continue
  1321. self._log(f" Lade herunter: {name}...")
  1322. if extract:
  1323. # ZIP herunterladen und entpacken
  1324. zip_path = "/tmp/trixy_model_download.zip"
  1325. code, _ = await self._run_cmd(
  1326. "wget", "-q", "--show-progress", "-O", zip_path, url,
  1327. )
  1328. if code != 0:
  1329. self._log(f" WARNUNG: Download fehlgeschlagen — {name}")
  1330. continue
  1331. await self._run_sudo("mkdir", "-p", target)
  1332. code, _ = await self._run_cmd(
  1333. "unzip", "-q", "-o", zip_path, "-d", target,
  1334. )
  1335. if os.path.exists(zip_path):
  1336. os.unlink(zip_path)
  1337. if code != 0:
  1338. self._log(f" WARNUNG: Entpacken fehlgeschlagen — {name}")
  1339. continue
  1340. else:
  1341. # Direkt herunterladen
  1342. target_dir = os.path.dirname(target)
  1343. await self._run_sudo("mkdir", "-p", target_dir)
  1344. code, _ = await self._run_cmd(
  1345. "wget", "-q", "--show-progress", "-O", target, url,
  1346. )
  1347. if code != 0:
  1348. self._log(f" WARNUNG: Download fehlgeschlagen — {name}")
  1349. continue
  1350. # Groesse ermitteln
  1351. if os.path.isfile(target):
  1352. total_size += os.path.getsize(target) // (1024 * 1024)
  1353. elif os.path.isdir(target):
  1354. for dirpath, _, filenames in os.walk(target):
  1355. for f in filenames:
  1356. total_size += os.path.getsize(os.path.join(dirpath, f)) // (1024 * 1024)
  1357. downloaded += 1
  1358. self._log(f" -> {name} installiert")
  1359. self._log(f" {downloaded} Modell(e) heruntergeladen ({total_size} MB)")
  1360. return True
  1361. async def step_verify(self) -> bool:
  1362. """Verifiziert die Installation."""
  1363. checks = ["ffmpeg", "python3"]
  1364. all_ok = True
  1365. for cmd in checks:
  1366. path = shutil.which(cmd)
  1367. if path:
  1368. self._log(f" OK: {cmd} ({path})")
  1369. else:
  1370. self._log(f" FEHLT: {cmd}")
  1371. all_ok = False
  1372. # venv pruefen
  1373. venv_python = f"{self._config.venv_path}/bin/python"
  1374. if os.path.isfile(venv_python):
  1375. self._log(f" OK: venv ({venv_python})")
  1376. else:
  1377. self._log(f" FEHLT: venv ({venv_python})")
  1378. all_ok = False
  1379. # Temporaere Verzeichnisse aufraeumen (pip-Cache, TMPDIR)
  1380. for tmp_name in ("tmp", "pip-cache"):
  1381. tmp_path = f"{self._config.install_path}/{tmp_name}"
  1382. if os.path.isdir(tmp_path):
  1383. await self._run_sudo("rm", "-rf", tmp_path)
  1384. self._log(f" Aufgeraeumt: {tmp_name}")
  1385. return all_ok