runner.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  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. def cancel(self) -> None:
  30. """Bricht die Installation ab."""
  31. self._cancelled = True
  32. if self._current_process and self._current_process.returncode is None:
  33. self._current_process.terminate()
  34. async def run(self) -> bool:
  35. """
  36. Fuehrt alle Schritte aus.
  37. Returns:
  38. True bei Erfolg, False bei Fehler/Abbruch.
  39. """
  40. steps = get_steps(self._config.mode)
  41. total_weight = sum(s.weight for s in steps)
  42. completed_weight = 0
  43. self._log(f"=== Trixy {self._config.mode.title()} Installation ===")
  44. self._log(f"Version: {self._config.version}")
  45. self._log(f"Ziel: {self._config.install_path}")
  46. self._log("")
  47. # Sudo-Rechte vorab sicherstellen
  48. needs_sudo = any(
  49. s.command_fn in (
  50. "step_apt_packages", "step_hostname", "step_wifi_ap",
  51. "step_directories", "step_systemd",
  52. )
  53. for s in steps
  54. )
  55. if needs_sudo:
  56. self._log("Pruefe sudo-Rechte...")
  57. code, _ = await self._run_cmd("sudo", "-v", check=False)
  58. if code != 0:
  59. self._log(" FEHLER: sudo-Rechte benoetigt aber nicht verfuegbar.")
  60. self._log(" Bitte Installer mit 'sudo ./install-{mode}.sh' starten")
  61. self._log(" oder den Benutzer zur sudoers-Gruppe hinzufuegen.")
  62. return False
  63. self._log(" -> sudo verfuegbar\n")
  64. for i, step in enumerate(steps, 1):
  65. if self._cancelled:
  66. self._log("\n[ABBRUCH] Installation wurde abgebrochen.")
  67. return False
  68. self._log(f"[{i}/{len(steps)}] {step.name}...")
  69. self._progress(completed_weight / total_weight, step.name)
  70. method = getattr(self, step.command_fn, None)
  71. if method is None:
  72. self._log(f" WARNUNG: Methode {step.command_fn} nicht implementiert")
  73. completed_weight += step.weight
  74. continue
  75. try:
  76. success = await method()
  77. if not success:
  78. self._log(f" FEHLER bei Schritt: {step.name}")
  79. return False
  80. except Exception as e:
  81. self._log(f" FEHLER: {e}")
  82. return False
  83. completed_weight += step.weight
  84. self._progress(completed_weight / total_weight, step.name)
  85. self._log(f" -> {step.name} abgeschlossen\n")
  86. self._progress(1.0, "Fertig")
  87. self._log("=== Installation erfolgreich abgeschlossen ===")
  88. return True
  89. # --- Hilfsmethoden ---
  90. _ANSI_RE = None # Lazy-compiled Regex
  91. @staticmethod
  92. def _clean_ansi(text: str) -> str:
  93. """Entfernt ANSI Escape-Sequenzen aus Text."""
  94. import re
  95. if InstallRunner._ANSI_RE is None:
  96. InstallRunner._ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
  97. return InstallRunner._ANSI_RE.sub("", text)
  98. async def _run_cmd(self, *args: str, check: bool = True) -> tuple[int, str]:
  99. """Fuehrt einen Befehl aus und loggt die Ausgabe."""
  100. self._log(f" $ {' '.join(args)}")
  101. # TMPDIR auf Installationspfad umleiten (tmpfs auf Pi zu klein fuer pip)
  102. env = os.environ.copy()
  103. tmp_dir = f"{self._config.install_path}/tmp"
  104. os.makedirs(tmp_dir, exist_ok=True)
  105. env["TMPDIR"] = tmp_dir
  106. env["PIP_CACHE_DIR"] = f"{self._config.install_path}/pip-cache"
  107. proc = await asyncio.create_subprocess_exec(
  108. *args,
  109. stdout=asyncio.subprocess.PIPE,
  110. stderr=asyncio.subprocess.STDOUT,
  111. env=env,
  112. )
  113. self._current_process = proc
  114. output_lines: list[str] = []
  115. assert proc.stdout is not None
  116. while True:
  117. line = await proc.stdout.readline()
  118. if not line:
  119. break
  120. decoded = line.decode("utf-8", errors="replace").rstrip()
  121. # \r-basierte Progress-Zeilen: nur letzten Teil nehmen
  122. if "\r" in decoded:
  123. decoded = decoded.rsplit("\r", 1)[-1].strip()
  124. # ANSI-Codes entfernen
  125. decoded = self._clean_ansi(decoded)
  126. if not decoded:
  127. continue
  128. output_lines.append(decoded)
  129. self._log(f" {decoded}")
  130. if self._cancelled:
  131. proc.terminate()
  132. await proc.wait()
  133. return -1, "Abgebrochen"
  134. await proc.wait()
  135. self._current_process = None
  136. output = "\n".join(output_lines)
  137. if check and proc.returncode != 0:
  138. self._log(f" Befehl fehlgeschlagen (Exit-Code: {proc.returncode})")
  139. return proc.returncode or 0, output
  140. async def _run_sudo(self, *args: str, check: bool = True) -> tuple[int, str]:
  141. """Fuehrt einen Befehl mit sudo aus."""
  142. return await self._run_cmd("sudo", *args, check=check)
  143. # --- Installations-Schritte ---
  144. async def step_extract(self) -> bool:
  145. """Entpackt das Quellarchiv nach source/."""
  146. archive = self._config.source_archive
  147. source = self._config.source_path # install_path/source
  148. if not os.path.isfile(archive):
  149. self._log(f" Archiv nicht gefunden: {archive}")
  150. return False
  151. # source-Verzeichnis erstellen
  152. code, _ = await self._run_sudo("mkdir", "-p", source)
  153. if code != 0:
  154. return False
  155. # Besitzer setzen
  156. await self._run_sudo("chown", self._config.username, self._config.install_path)
  157. # Entpacken — strip-components=1 entfernt den Prefix (trixy-server-1.0.0/)
  158. self._log(f" Entpacke nach {source}...")
  159. code, _ = await self._run_cmd(
  160. "tar", "xzf", archive, "-C", source, "--strip-components=1",
  161. )
  162. return code == 0
  163. # Paketnamen pro Paketmanager
  164. # Schluessel = kanonischer Name, Werte = {pm: paketname}
  165. _PKG_MAP: dict[str, dict[str, str]] = {
  166. "python3": {"apt": "python3", "dnf": "python3", "pacman": "python", "zypper": "python3"},
  167. "python3-venv": {"apt": "python3-venv", "dnf": "python3-venv", "pacman": "python", "zypper": "python3-venv"},
  168. "python3-pip": {"apt": "python3-pip", "dnf": "python3-pip", "pacman": "python-pip", "zypper": "python3-pip"},
  169. "python3-dev": {"apt": "python3-dev", "dnf": "python3-devel", "pacman": "python", "zypper": "python3-devel"},
  170. "git": {"apt": "git", "dnf": "git", "pacman": "git", "zypper": "git"},
  171. "curl": {"apt": "curl", "dnf": "curl", "pacman": "curl", "zypper": "curl"},
  172. "wget": {"apt": "wget", "dnf": "wget", "pacman": "wget", "zypper": "wget"},
  173. "alsa-utils": {"apt": "alsa-utils", "dnf": "alsa-utils", "pacman": "alsa-utils", "zypper": "alsa-utils"},
  174. "ffmpeg": {"apt": "ffmpeg", "dnf": "ffmpeg-free", "pacman": "ffmpeg", "zypper": "ffmpeg"},
  175. "portaudio-dev": {"apt": "portaudio19-dev", "dnf": "portaudio-devel", "pacman": "portaudio", "zypper": "portaudio-devel"},
  176. "alsa-dev": {"apt": "libasound2-dev", "dnf": "alsa-lib-devel", "pacman": "alsa-lib", "zypper": "alsa-devel"},
  177. "openblas-dev": {"apt": "libopenblas-dev", "dnf": "openblas-devel", "pacman": "openblas", "zypper": "openblas-devel"},
  178. "lapack-dev": {"apt": "liblapack-dev", "dnf": "lapack-devel", "pacman": "lapack", "zypper": "lapack-devel"},
  179. "gfortran": {"apt": "gfortran", "dnf": "gcc-gfortran", "pacman": "gcc-fortran", "zypper": "gcc-fortran"},
  180. "ffi-dev": {"apt": "libffi-dev", "dnf": "libffi-devel", "pacman": "libffi", "zypper": "libffi-devel"},
  181. "ssl-dev": {"apt": "libssl-dev", "dnf": "openssl-devel", "pacman": "openssl", "zypper": "libopenssl-devel"},
  182. "hostapd": {"apt": "hostapd", "dnf": "hostapd", "pacman": "hostapd", "zypper": "hostapd"},
  183. "dnsmasq": {"apt": "dnsmasq", "dnf": "dnsmasq", "pacman": "dnsmasq", "zypper": "dnsmasq"},
  184. "iptables": {"apt": "iptables", "dnf": "iptables", "pacman": "iptables", "zypper": "iptables"},
  185. "cmake": {"apt": "cmake", "dnf": "cmake", "pacman": "cmake", "zypper": "cmake"},
  186. "g++": {"apt": "g++", "dnf": "gcc-c++", "pacman": "gcc", "zypper": "gcc-c++"},
  187. "evtest": {"apt": "evtest", "dnf": "evtest", "pacman": "evtest", "zypper": "evtest"},
  188. "input-utils": {"apt": "input-utils", "dnf": "input-utils", "pacman": "input-utils", "zypper": "input-utils"},
  189. }
  190. def _detect_pkg_manager(self) -> tuple[str, list[str], list[str], list[str]]:
  191. """
  192. Erkennt den Paketmanager.
  193. Returns:
  194. (name, update_cmd, install_cmd, check_cmd)
  195. install_cmd hat Platzhalter — Pakete werden angehaengt.
  196. """
  197. managers = [
  198. ("apt", ["apt-get", "update", "-qq"], ["apt-get", "install", "-y", "-qq"], ["dpkg", "-s"]),
  199. ("dnf", ["dnf", "check-update"], ["dnf", "install", "-y", "-q"], ["rpm", "-q"]),
  200. ("pacman", ["pacman", "-Sy"], ["pacman", "-S", "--noconfirm", "--needed"], ["pacman", "-Q"]),
  201. ("zypper", ["zypper", "refresh", "-q"], ["zypper", "install", "-y", "-q"], ["rpm", "-q"]),
  202. ]
  203. for name, update, install, check in managers:
  204. if shutil.which(install[0]):
  205. return name, update, install, check
  206. return "", [], [], []
  207. def _resolve_pkg(self, canonical: str, pm: str) -> str:
  208. """Loest einen kanonischen Paketnamen fuer den erkannten Paketmanager auf."""
  209. mapping = self._PKG_MAP.get(canonical, {})
  210. return mapping.get(pm, canonical)
  211. async def _install_pkg_group(
  212. self, name: str, canonical_names: list[str], required: bool = True,
  213. ) -> bool:
  214. """Installiert eine Paketgruppe mit dem erkannten Paketmanager."""
  215. pm_name, _, install_cmd, _ = self._detect_pkg_manager()
  216. if not pm_name:
  217. self._log(f" FEHLER: Kein unterstuetzter Paketmanager gefunden (apt/dnf/pacman/zypper)")
  218. return not required
  219. # Paketnamen aufloesen
  220. packages = []
  221. for canonical in canonical_names:
  222. resolved = self._resolve_pkg(canonical, pm_name)
  223. if resolved not in packages:
  224. packages.append(resolved)
  225. self._log(f" {name} ({pm_name}): {', '.join(packages)}")
  226. code, _ = await self._run_sudo(*install_cmd, *packages)
  227. if code != 0:
  228. # Einzeln versuchen
  229. self._log(f" Gruppeninstallation fehlgeschlagen — installiere einzeln...")
  230. failed = []
  231. for pkg in packages:
  232. c, _ = await self._run_sudo(*install_cmd, pkg)
  233. if c != 0:
  234. failed.append(pkg)
  235. if failed:
  236. self._log(f" WARNUNG: Nicht verfuegbar: {', '.join(failed)}")
  237. if required:
  238. return False
  239. return True
  240. async def step_apt_packages(self) -> bool:
  241. """Installiert System-Pakete in Gruppen."""
  242. pm_name, update_cmd, _, _ = self._detect_pkg_manager()
  243. if not pm_name:
  244. self._log(" FEHLER: Kein unterstuetzter Paketmanager gefunden")
  245. self._log(" Unterstuetzt: apt (Debian/Ubuntu), dnf (Fedora/RHEL),")
  246. self._log(" pacman (Arch), zypper (openSUSE)")
  247. self._log(" Bitte Pakete manuell installieren.")
  248. return False
  249. self._log(f" Paketmanager: {pm_name}")
  250. self._log(f" Aktualisiere Paketlisten...")
  251. # dnf check-update gibt Exit 100 bei verfuegbaren Updates — das ist OK
  252. code, _ = await self._run_sudo(*update_cmd, check=False)
  253. if code != 0 and pm_name != "dnf":
  254. self._log(f" WARNUNG: Paketlisten-Update fehlgeschlagen")
  255. # Runtime-Pakete (immer benoetigt)
  256. runtime = ["python3", "python3-venv", "python3-pip", "git", "curl", "wget"]
  257. if not await self._install_pkg_group("Runtime", runtime):
  258. return False
  259. # Audio-Runtime
  260. audio = ["alsa-utils", "ffmpeg"]
  261. if not await self._install_pkg_group("Audio", audio):
  262. return False
  263. # Build-Abhaengigkeiten (optional auf x86_64 mit Wheels)
  264. build = [
  265. "python3-dev", "portaudio-dev", "alsa-dev",
  266. "openblas-dev", "lapack-dev", "gfortran",
  267. "ffi-dev", "ssl-dev",
  268. ]
  269. await self._install_pkg_group("Build-Abhaengigkeiten", build, required=False)
  270. # HID / Input-Tools (Client/Standalone — Konferenzmikrofone, Headsets)
  271. if self._config.mode in ("client", "standalone"):
  272. hid = ["evtest", "input-utils"]
  273. await self._install_pkg_group("HID/Input", hid, required=False)
  274. # WiFi-Hotspot (nur Server)
  275. if self._config.mode == "server" and self._config.wifi_enabled:
  276. wifi = ["hostapd", "dnsmasq", "iptables"]
  277. if not await self._install_pkg_group("WiFi-Hotspot", wifi):
  278. return False
  279. return True
  280. async def step_hostname(self) -> bool:
  281. """Setzt den Hostnamen."""
  282. code, _ = await self._run_sudo(
  283. "hostnamectl", "set-hostname", self._config.hostname,
  284. )
  285. return code == 0
  286. async def step_wifi_connect(self) -> bool:
  287. """Konfiguriert WLAN-Verbindung (Client/Standalone)."""
  288. if not self._config.wifi_connect:
  289. self._log(" Keine WLAN-Aenderung — uebersprungen")
  290. return True
  291. ssid = self._config.wifi_connect_ssid
  292. password = self._config.wifi_connect_password
  293. self._log(f" Konfiguriere WLAN: {ssid}")
  294. # Methode 1: nmcli (NetworkManager — modernes Debian/Ubuntu/Raspberry Pi OS)
  295. if shutil.which("nmcli"):
  296. self._log(" Verwende NetworkManager (nmcli)...")
  297. # Bestehende Verbindung mit gleichem Namen entfernen
  298. await self._run_cmd(
  299. "nmcli", "connection", "delete", ssid, check=False,
  300. )
  301. # Neue Verbindung erstellen
  302. code, _ = await self._run_sudo(
  303. "nmcli", "device", "wifi", "connect", ssid,
  304. "password", password,
  305. )
  306. if code == 0:
  307. self._log(f" -> WLAN verbunden: {ssid}")
  308. return True
  309. else:
  310. self._log(f" WARNUNG: nmcli fehlgeschlagen — versuche wpa_supplicant")
  311. # Methode 2: wpa_supplicant (aeltere Systeme / Raspberry Pi OS Lite)
  312. self._log(" Verwende wpa_supplicant...")
  313. wpa_conf = "/etc/wpa_supplicant/wpa_supplicant.conf"
  314. # wpa_passphrase generiert verschluesselten PSK
  315. code, output = await self._run_cmd(
  316. "wpa_passphrase", ssid, password, check=False,
  317. )
  318. if code != 0:
  319. # Fallback: Klartext-Config
  320. network_block = f"""
  321. network={{
  322. ssid="{ssid}"
  323. psk="{password}"
  324. key_mgmt=WPA-PSK
  325. }}
  326. """
  327. else:
  328. network_block = output
  329. # Pruefen ob Basis-Header vorhanden
  330. wpa_header = (
  331. 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n'
  332. 'update_config=1\n'
  333. 'country=DE\n'
  334. )
  335. # Config schreiben
  336. proc = await asyncio.create_subprocess_exec(
  337. "sudo", "tee", wpa_conf,
  338. stdin=asyncio.subprocess.PIPE,
  339. stdout=asyncio.subprocess.DEVNULL,
  340. )
  341. full_config = wpa_header + "\n" + network_block
  342. await proc.communicate(full_config.encode())
  343. # wlan0 neu verbinden
  344. await self._run_sudo("wpa_cli", "-i", "wlan0", "reconfigure", check=False)
  345. self._log(f" -> WLAN konfiguriert: {ssid}")
  346. return True
  347. async def step_wifi_ap(self) -> bool:
  348. """Konfiguriert WiFi Access Point."""
  349. if not self._config.wifi_enabled:
  350. self._log(" WiFi-Hotspot deaktiviert — uebersprungen")
  351. return True
  352. cfg = self._config
  353. # hostapd konfigurieren
  354. hostapd_conf = f"""# Trixy Access Point
  355. interface=wlan0
  356. driver=nl80211
  357. ssid={cfg.wifi_ssid}
  358. hw_mode=g
  359. channel={cfg.wifi_channel}
  360. wmm_enabled=0
  361. macaddr_acl=0
  362. auth_algs=1
  363. ignore_broadcast_ssid=0
  364. wpa=2
  365. wpa_passphrase={cfg.wifi_password}
  366. wpa_key_mgmt=WPA-PSK
  367. wpa_pairwise=TKIP
  368. rsn_pairwise=CCMP
  369. country_code=DE
  370. ieee80211n=1
  371. ieee80211d=1
  372. """
  373. # Ueber tee schreiben (braucht sudo)
  374. proc = await asyncio.create_subprocess_exec(
  375. "sudo", "tee", "/etc/hostapd/hostapd.conf",
  376. stdin=asyncio.subprocess.PIPE,
  377. stdout=asyncio.subprocess.DEVNULL,
  378. )
  379. await proc.communicate(hostapd_conf.encode())
  380. # dnsmasq konfigurieren
  381. dnsmasq_conf = f"""# Trixy DHCP-Server
  382. interface=wlan0
  383. bind-interfaces
  384. dhcp-range={cfg.dhcp_range_start},{cfg.dhcp_range_end},255.255.255.0,12h
  385. server=8.8.8.8
  386. server=8.8.4.4
  387. address=/{cfg.wifi_ssid}/{cfg.ap_ip}
  388. """
  389. proc = await asyncio.create_subprocess_exec(
  390. "sudo", "tee", "/etc/dnsmasq.conf",
  391. stdin=asyncio.subprocess.PIPE,
  392. stdout=asyncio.subprocess.DEVNULL,
  393. )
  394. await proc.communicate(dnsmasq_conf.encode())
  395. # dhcpcd — statische IP
  396. dhcpcd_block = f"""
  397. # Trixy AP — wlan0 statische IP
  398. interface wlan0
  399. static ip_address={cfg.ap_ip}/24
  400. nohook wpa_supplicant
  401. """
  402. proc = await asyncio.create_subprocess_exec(
  403. "sudo", "tee", "-a", "/etc/dhcpcd.conf",
  404. stdin=asyncio.subprocess.PIPE,
  405. stdout=asyncio.subprocess.DEVNULL,
  406. )
  407. await proc.communicate(dhcpcd_block.encode())
  408. # IP-Forwarding und NAT
  409. await self._run_sudo("sysctl", "-w", "net.ipv4.ip_forward=1")
  410. await self._run_sudo(
  411. "iptables", "-t", "nat", "-A", "POSTROUTING", "-o", "eth0", "-j", "MASQUERADE",
  412. )
  413. # Services aktivieren
  414. await self._run_sudo("systemctl", "unmask", "hostapd")
  415. await self._run_sudo("systemctl", "enable", "hostapd")
  416. await self._run_sudo("systemctl", "enable", "dnsmasq")
  417. return True
  418. @staticmethod
  419. def _parse_requirements(path: str) -> list[str]:
  420. """
  421. Liest eine requirements.txt und gibt aktive Paketnamen zurueck.
  422. Ignoriert leere Zeilen, Kommentare (#) und Options-Zeilen (--, -r).
  423. """
  424. packages: list[str] = []
  425. try:
  426. with open(path) as f:
  427. for line in f:
  428. line = line.strip()
  429. # Leer, Kommentar, Option
  430. if not line or line.startswith("#") or line.startswith("-"):
  431. continue
  432. # Inline-Kommentar entfernen: "numpy # Array-Verarbeitung"
  433. if " #" in line:
  434. line = line.split(" #")[0].strip()
  435. if line:
  436. packages.append(line)
  437. except OSError:
  438. pass
  439. return packages
  440. async def _pip_install_packages(
  441. self, pip: str, packages: list[str], label: str = "",
  442. ) -> tuple[int, int]:
  443. """
  444. Installiert Pakete einzeln via pip.
  445. Jedes Paket wird separat installiert — so bleibt /tmp sauber
  446. und der Fortschritt ist sichtbar.
  447. Args:
  448. pip: Pfad zu pip im venv
  449. packages: Liste der zu installierenden Pakete
  450. label: Kontext-Label fuer Log (z.B. "Kern" oder Plugin-Name)
  451. Returns:
  452. (erfolgreich, fehlgeschlagen)
  453. """
  454. success = 0
  455. failed = 0
  456. total = len(packages)
  457. for i, pkg in enumerate(packages, 1):
  458. if self._cancelled:
  459. return success, failed
  460. prefix = f" [{i}/{total}]" if total > 1 else " "
  461. self._log(f"{prefix} {pkg}...")
  462. code, _ = await self._run_cmd(pip, "install", pkg)
  463. if code == 0:
  464. success += 1
  465. else:
  466. failed += 1
  467. self._log(f" WARNUNG: {pkg} fehlgeschlagen")
  468. return success, failed
  469. async def step_venv(self) -> bool:
  470. """Erstellt Python venv und installiert Requirements einzeln."""
  471. venv = self._config.venv_path
  472. source = self._config.source_path
  473. # venv erstellen
  474. self._log(" Erstelle Python venv...")
  475. code, _ = await self._run_cmd("python3", "-m", "venv", venv)
  476. if code != 0:
  477. return False
  478. # pip aktualisieren
  479. pip = f"{venv}/bin/pip"
  480. self._log(" Aktualisiere pip...")
  481. code, _ = await self._run_cmd(pip, "install", "--upgrade", "pip", "setuptools", "wheel")
  482. if code != 0:
  483. return False
  484. # Kern-Requirements einzeln installieren
  485. req_file = f"{source}/requirements.txt"
  486. packages = self._parse_requirements(req_file)
  487. if not packages:
  488. self._log(" Keine Requirements gefunden")
  489. return True
  490. self._log(f" Installiere {len(packages)} Kern-Pakete...\n")
  491. ok, fail = await self._pip_install_packages(pip, packages, "Kern")
  492. self._log(f"\n Ergebnis: {ok} installiert, {fail} fehlgeschlagen")
  493. if fail > 0:
  494. self._log(" WARNUNG: Einige Pakete konnten nicht installiert werden")
  495. # Nicht abbrechen — manche Pakete sind optional (z.B. kenlm)
  496. return True
  497. # Zusaetzliche pip-Pakete pro Plugin die nicht in requirements.txt stehen
  498. # (weil sie auskommentiert sind oder backend-abhaengig)
  499. _PLUGIN_EXTRA_DEPS: dict[str, list[str]] = {
  500. "stt_vosk": ["vosk"],
  501. "stt_whisper": ["openai-whisper"],
  502. "tts_coqui": ["TTS"],
  503. "tts_piper": ["piper-tts"],
  504. "tts_google": ["google-cloud-texttospeech"],
  505. "nlp_classifier": ["sentence-transformers", "onnxruntime", "transformers"],
  506. }
  507. # apt/system-Pakete die vor pip install noetig sind (fuer Kompilierung)
  508. _PLUGIN_SYSTEM_DEPS: dict[str, list[str]] = {
  509. "nlp_llm": ["cmake"], # llama-cpp-python braucht cmake zum Bauen
  510. }
  511. async def step_plugin_deps(self) -> bool:
  512. """Installiert Plugin-Abhaengigkeiten einzeln inkl. Backend-spezifischer Pakete."""
  513. pip = f"{self._config.venv_path}/bin/pip"
  514. source = self._config.source_path
  515. cfg = self._config
  516. total_ok = 0
  517. total_fail = 0
  518. for plugin in cfg.selected_plugins:
  519. if self._cancelled:
  520. return False
  521. # 1. requirements.txt parsen und einzeln installieren
  522. req_file = f"{source}/plugins/{plugin}/requirements.txt"
  523. packages = self._parse_requirements(req_file)
  524. # 2. Zusaetzliche Pakete hinzufuegen
  525. extras = self._PLUGIN_EXTRA_DEPS.get(plugin, [])
  526. packages.extend(extras)
  527. if not packages:
  528. continue
  529. self._log(f"\n Plugin: {plugin} ({len(packages)} Pakete)")
  530. ok, fail = await self._pip_install_packages(pip, packages, plugin)
  531. total_ok += ok
  532. total_fail += fail
  533. # 3. NLP Backend — spezielle Behandlung
  534. if "nlp_llm" in cfg.selected_plugins:
  535. await self._install_nlp_backend()
  536. if total_ok > 0 or total_fail > 0:
  537. self._log(f"\n Plugin-Pakete: {total_ok} installiert, {total_fail} fehlgeschlagen")
  538. return True
  539. async def _install_nlp_backend(self) -> None:
  540. """Installiert das gewaehlte NLP-Backend."""
  541. pip = f"{self._config.venv_path}/bin/pip"
  542. source = self._config.source_path
  543. backend = self._config.nlp_backend
  544. self._log(f" NLP Backend: {backend}")
  545. if backend == "llama_cpp":
  546. # cmake als System-Dep (fuer C++ Kompilierung)
  547. pm_name, _, install_cmd, _ = self._detect_pkg_manager()
  548. if pm_name:
  549. cmake_pkg = self._resolve_pkg("cmake", pm_name) if "cmake" in self._PKG_MAP else "cmake"
  550. self._log(f" Installiere cmake (Build-Abhaengigkeit)...")
  551. await self._run_sudo(*install_cmd, cmake_pkg, check=False)
  552. # llama-cpp-python installieren (kann auf ARM lange dauern)
  553. self._log(f" Installiere llama-cpp-python (kann 10-30 Min dauern auf ARM)...")
  554. code, _ = await self._run_cmd(
  555. pip, "install", "llama-cpp-python",
  556. )
  557. if code != 0:
  558. self._log(f" FEHLER: llama-cpp-python Installation fehlgeschlagen")
  559. self._log(f" Alternativ: Backend auf 'ollama' umstellen")
  560. else:
  561. self._log(f" -> llama-cpp-python installiert")
  562. elif backend in ("ollama", "ollama_remote"):
  563. # aiohttp fuer Ollama REST-API
  564. self._log(f" Installiere aiohttp (Ollama Client)...")
  565. code, _ = await self._run_cmd(pip, "install", "aiohttp")
  566. if code != 0:
  567. self._log(f" WARNUNG: aiohttp fehlgeschlagen")
  568. if backend == "ollama":
  569. # Lokales Ollama — pruefen ob installiert
  570. if not shutil.which("ollama"):
  571. self._log(f" WARNUNG: Ollama nicht gefunden!")
  572. self._log(f" Installieren mit: curl -fsSL https://ollama.com/install.sh | sh")
  573. self._log(f" Danach: ollama pull qwen2.5:1.5b")
  574. else:
  575. self._log(f" -> Ollama gefunden: {shutil.which('ollama')}")
  576. else:
  577. # Externer Ollama-Server
  578. host = self._config.ollama_host
  579. self._log(f" Externer Ollama-Server: {host}")
  580. self._log(f" Stelle sicher dass der Server erreichbar ist und")
  581. self._log(f" das Modell geladen wurde (ollama pull qwen2.5:1.5b)")
  582. # 4. Config anpassen
  583. # ollama_remote wird intern als "ollama" gespeichert (selbes Backend, anderer Host)
  584. config_backend = "ollama" if backend == "ollama_remote" else backend
  585. config_file = f"{source}/plugins/nlp_llm/config.json"
  586. if os.path.isfile(config_file):
  587. try:
  588. import json
  589. with open(config_file) as f:
  590. nlp_cfg = json.load(f)
  591. nlp_cfg["backend"] = config_backend
  592. nlp_cfg["ollama_host"] = self._config.ollama_host
  593. if config_backend == "ollama":
  594. nlp_cfg["model_name"] = nlp_cfg.get("model_name", "qwen2.5:1.5b")
  595. with open(config_file, "w") as f:
  596. json.dump(nlp_cfg, f, indent=4, ensure_ascii=False)
  597. self._log(f" -> config.json: backend={config_backend}, host={self._config.ollama_host}")
  598. except Exception as e:
  599. self._log(f" WARNUNG: Config-Update fehlgeschlagen: {e}")
  600. async def step_directories(self) -> bool:
  601. """Erstellt Verzeichnisse und setzt Berechtigungen."""
  602. source = self._config.source_path
  603. mode = self._config.mode
  604. # Basis-Verzeichnisse (alle Modi)
  605. dirs = [
  606. "data",
  607. "logs",
  608. "cache",
  609. "certs",
  610. ]
  611. # Modell-Verzeichnisse — exakte Struktur, verhindert Tippfehler
  612. model_dirs = [
  613. "models",
  614. "models/wakeword",
  615. "models/wakeword/trixy",
  616. "models/wakeword/system_command",
  617. "models/wakeword/alexa",
  618. "models/wakeword/hey_jarvis",
  619. "models/wakeword/hey_mycroft",
  620. "models/piper",
  621. "models/nlp",
  622. "models/symspell",
  623. ]
  624. # Server/Standalone-spezifisch
  625. if mode in ("server", "standalone"):
  626. dirs.extend([
  627. "satellites",
  628. "sync_data",
  629. "assets",
  630. "assets/default",
  631. ])
  632. # Plugin-Modell-Verzeichnisse
  633. for plugin in self._config.selected_plugins:
  634. dirs.append(f"plugins/{plugin}/models")
  635. # Client-spezifisch
  636. if mode == "client":
  637. dirs.extend([
  638. "assets",
  639. ])
  640. # Trainer-Verzeichnisse (Server/Standalone)
  641. if mode in ("server", "standalone"):
  642. dirs.extend([
  643. "trainer/data/raw",
  644. "trainer/data/processed",
  645. "trainer/cache",
  646. "trainer/assets",
  647. ])
  648. all_dirs = dirs + model_dirs
  649. self._log(f" Erstelle {len(all_dirs)} Verzeichnisse...")
  650. for d in all_dirs:
  651. path = f"{source}/{d}"
  652. await self._run_sudo("mkdir", "-p", path)
  653. # Shell-Scripte ausfuehrbar machen
  654. scripts = [
  655. "run_linux.sh",
  656. "install_linux.sh",
  657. "install_requirements.sh",
  658. "bash/update_linux.sh",
  659. "bash/prepare_linux.sh",
  660. ]
  661. made_exec = 0
  662. for script in scripts:
  663. path = f"{source}/{script}"
  664. if os.path.isfile(path):
  665. await self._run_cmd("chmod", "+x", path)
  666. made_exec += 1
  667. # User zur 'input'-Gruppe hinzufuegen (fuer HID Media Keys / /dev/input/event*)
  668. if mode in ("client", "standalone"):
  669. code, _ = await self._run_sudo(
  670. "usermod", "-aG", "input", self._config.username, check=False,
  671. )
  672. if code == 0:
  673. self._log(f" -> User '{self._config.username}' zur Gruppe 'input' hinzugefuegt (HID)")
  674. # Besitzer setzen
  675. await self._run_sudo(
  676. "chown", "-R",
  677. f"{self._config.username}:{self._config.username}",
  678. self._config.install_path,
  679. )
  680. self._log(f" -> {len(all_dirs)} Verzeichnisse erstellt, {made_exec} Scripte ausfuehrbar")
  681. return True
  682. async def step_systemd(self) -> bool:
  683. """Erstellt den systemd-Service und aktiviert ihn bei Bedarf."""
  684. cfg = self._config
  685. if not cfg.install_service:
  686. self._log(" Service-Installation deaktiviert — uebersprungen")
  687. return True
  688. service_name = f"trixy-{cfg.mode}"
  689. # ExecStart zusammenbauen
  690. # run_linux.sh fuehrt Update + Bereinigung + Start durch
  691. run_script = f"{cfg.source_path}/run_linux.sh"
  692. exec_args = ["/bin/bash", run_script]
  693. if cfg.mode == "client":
  694. exec_args.extend([
  695. "client",
  696. "--host", cfg.server_host,
  697. "--port", str(cfg.server_port),
  698. "--room", cfg.room,
  699. "--alias", cfg.alias,
  700. ])
  701. else:
  702. exec_args.append(cfg.mode)
  703. if cfg.auto_regist and cfg.mode in ("server", "standalone"):
  704. exec_args.append("--auto-regist")
  705. exec_args.append("--debug")
  706. exec_start = " ".join(exec_args)
  707. # After-Deps fuer Server mit WiFi-AP
  708. after_deps = "network-online.target"
  709. if cfg.mode == "server" and cfg.wifi_enabled:
  710. after_deps = "network-online.target hostapd.service dnsmasq.service"
  711. service_content = f"""[Unit]
  712. Description=Trixy Voice Assistant ({cfg.mode.title()})
  713. After={after_deps}
  714. Wants=network-online.target
  715. [Service]
  716. Type=simple
  717. User={cfg.username}
  718. Group={cfg.username}
  719. WorkingDirectory={cfg.source_path}
  720. ExecStart={exec_start}
  721. Restart=on-failure
  722. RestartSec=5
  723. StartLimitIntervalSec=60
  724. StartLimitBurst=5
  725. Environment=PYTHONUNBUFFERED=1
  726. Environment=HOME=/home/{cfg.username}
  727. StandardOutput=journal
  728. StandardError=journal
  729. SyslogIdentifier=trixy
  730. [Install]
  731. WantedBy=multi-user.target
  732. """
  733. # Service-Datei schreiben
  734. proc = await asyncio.create_subprocess_exec(
  735. "sudo", "tee", f"/etc/systemd/system/{service_name}.service",
  736. stdin=asyncio.subprocess.PIPE,
  737. stdout=asyncio.subprocess.DEVNULL,
  738. )
  739. await proc.communicate(service_content.encode())
  740. await self._run_sudo("systemctl", "daemon-reload")
  741. # Autostart je nach Einstellung
  742. if cfg.autostart:
  743. await self._run_sudo("systemctl", "enable", f"{service_name}.service")
  744. self._log(f" -> {service_name}.service aktiviert (Autostart)")
  745. else:
  746. await self._run_sudo("systemctl", "disable", f"{service_name}.service")
  747. self._log(f" -> {service_name}.service erstellt (kein Autostart)")
  748. self._log(f" Manuell starten: sudo systemctl start {service_name}")
  749. return True
  750. def _get_model_downloads(self) -> list[tuple[str, str, str, bool, str | None]]:
  751. """
  752. Gibt die Download-Liste basierend auf Hardware-Empfehlung zurueck.
  753. Returns:
  754. Liste von (name, url, ziel_relativ_zu_source, entpacken?, plugin_name).
  755. plugin_name ist None fuer Core-Modelle.
  756. """
  757. from .system_info import detect_system, get_model_recommendations
  758. info = detect_system()
  759. recs = get_model_recommendations(info, self._config.selected_plugins)
  760. downloads: list[tuple[str, str, str, bool, str | None]] = []
  761. # Empfohlene Modelle ermitteln
  762. rec_names = {r.name for r in recs if r.recommended}
  763. self._log(f" Hardware: {info.ram_total_mb}MB RAM, {info.disk_free_mb}MB frei")
  764. if info.gpu.cuda_available:
  765. self._log(f" GPU: {info.gpu.name} ({info.gpu.vram_mb}MB VRAM)")
  766. else:
  767. self._log(" GPU: Keine CUDA-GPU erkannt")
  768. self._log(f" {len(rec_names)} empfohlene Modelle erkannt")
  769. self._log("")
  770. # --- STT Vosk ---
  771. if "stt_vosk" in self._config.selected_plugins:
  772. if "Vosk STT (Deutsch, gross)" in rec_names:
  773. downloads.append((
  774. "Vosk STT (Deutsch, gross)",
  775. "https://alphacephei.com/vosk/models/vosk-model-de-0.21.zip",
  776. "plugins/stt_vosk/models",
  777. True, "stt_vosk",
  778. ))
  779. elif "Vosk STT (Deutsch, klein)" in rec_names:
  780. downloads.append((
  781. "Vosk STT (Deutsch, klein)",
  782. "https://alphacephei.com/vosk/models/vosk-model-small-de-0.15.zip",
  783. "plugins/stt_vosk/models",
  784. True, "stt_vosk",
  785. ))
  786. # --- STT Whisper ---
  787. if "stt_whisper" in self._config.selected_plugins:
  788. # Bestes empfohlenes Whisper-Modell waehlen
  789. whisper_priority = ["medium", "small", "base", "tiny"]
  790. for model_name in whisper_priority:
  791. full_name = f"Whisper STT ({model_name})"
  792. if full_name in rec_names:
  793. self._log(f" Whisper: {model_name} empfohlen (wird beim Start geladen)")
  794. break
  795. # --- TTS Piper ---
  796. if "tts_piper" in self._config.selected_plugins:
  797. if "Piper TTS (thorsten-medium)" in rec_names:
  798. downloads.append((
  799. "Piper TTS (thorsten-medium, onnx)",
  800. "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
  801. "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx",
  802. "models/piper/de_DE-thorsten-medium.onnx",
  803. False, None,
  804. ))
  805. downloads.append((
  806. "Piper TTS (thorsten-medium, config)",
  807. "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
  808. "de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json",
  809. "models/piper/de_DE-thorsten-medium.onnx.json",
  810. False, None,
  811. ))
  812. # --- NLP LLM ---
  813. if "nlp_llm" in self._config.selected_plugins:
  814. if "Qwen 2.5 1.5B (Q4_K_M)" in rec_names:
  815. downloads.append((
  816. "Qwen 2.5 1.5B (Q4_K_M)",
  817. "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/"
  818. "qwen2.5-1.5b-instruct-q4_k_m.gguf",
  819. "models/nlp/qwen2.5-1.5b-instruct-q4_k_m.gguf",
  820. False, None,
  821. ))
  822. return downloads
  823. async def step_download_models(self) -> bool:
  824. """Laedt ML-Modelle basierend auf Hardware-Empfehlung herunter."""
  825. if not self._config.download_models:
  826. self._log(" Modell-Download deaktiviert — uebersprungen")
  827. return True
  828. source = self._config.source_path
  829. downloads = self._get_model_downloads()
  830. if not downloads:
  831. self._log(" Keine Modelle zum Herunterladen (keine passenden Empfehlungen)")
  832. return True
  833. downloaded = 0
  834. total_size = 0
  835. for name, url, target_rel, extract, plugin in downloads:
  836. if self._cancelled:
  837. return False
  838. target = f"{source}/{target_rel}"
  839. # Pruefen ob bereits vorhanden
  840. if not extract and os.path.isfile(target):
  841. self._log(f" {name} — bereits vorhanden")
  842. continue
  843. if extract and os.path.isdir(target) and os.listdir(target):
  844. self._log(f" {name} — bereits vorhanden")
  845. continue
  846. self._log(f" Lade herunter: {name}...")
  847. if extract:
  848. # ZIP herunterladen und entpacken
  849. zip_path = "/tmp/trixy_model_download.zip"
  850. code, _ = await self._run_cmd(
  851. "wget", "-q", "--show-progress", "-O", zip_path, url,
  852. )
  853. if code != 0:
  854. self._log(f" WARNUNG: Download fehlgeschlagen — {name}")
  855. continue
  856. await self._run_sudo("mkdir", "-p", target)
  857. code, _ = await self._run_cmd(
  858. "unzip", "-q", "-o", zip_path, "-d", target,
  859. )
  860. if os.path.exists(zip_path):
  861. os.unlink(zip_path)
  862. if code != 0:
  863. self._log(f" WARNUNG: Entpacken fehlgeschlagen — {name}")
  864. continue
  865. else:
  866. # Direkt herunterladen
  867. target_dir = os.path.dirname(target)
  868. await self._run_sudo("mkdir", "-p", target_dir)
  869. code, _ = await self._run_cmd(
  870. "wget", "-q", "--show-progress", "-O", target, url,
  871. )
  872. if code != 0:
  873. self._log(f" WARNUNG: Download fehlgeschlagen — {name}")
  874. continue
  875. # Groesse ermitteln
  876. if os.path.isfile(target):
  877. total_size += os.path.getsize(target) // (1024 * 1024)
  878. elif os.path.isdir(target):
  879. for dirpath, _, filenames in os.walk(target):
  880. for f in filenames:
  881. total_size += os.path.getsize(os.path.join(dirpath, f)) // (1024 * 1024)
  882. downloaded += 1
  883. self._log(f" -> {name} installiert")
  884. self._log(f" {downloaded} Modell(e) heruntergeladen ({total_size} MB)")
  885. return True
  886. async def step_verify(self) -> bool:
  887. """Verifiziert die Installation."""
  888. checks = ["ffmpeg", "python3"]
  889. all_ok = True
  890. for cmd in checks:
  891. path = shutil.which(cmd)
  892. if path:
  893. self._log(f" OK: {cmd} ({path})")
  894. else:
  895. self._log(f" FEHLT: {cmd}")
  896. all_ok = False
  897. # venv pruefen
  898. venv_python = f"{self._config.venv_path}/bin/python"
  899. if os.path.isfile(venv_python):
  900. self._log(f" OK: venv ({venv_python})")
  901. else:
  902. self._log(f" FEHLT: venv ({venv_python})")
  903. all_ok = False
  904. # Temporaere Verzeichnisse aufraeumen (pip-Cache, TMPDIR)
  905. for tmp_name in ("tmp", "pip-cache"):
  906. tmp_path = f"{self._config.install_path}/{tmp_name}"
  907. if os.path.isdir(tmp_path):
  908. await self._run_sudo("rm", "-rf", tmp_path)
  909. self._log(f" Aufgeraeumt: {tmp_name}")
  910. return all_ok