detect.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. # -*- coding: utf-8 -*-
  2. """
  3. Erkennung bestehender Trixy-Installationen.
  4. Liest Version, Modus, Service-Status und Einstellungen
  5. einer bereits installierten Trixy-Instanz.
  6. """
  7. from __future__ import annotations
  8. import json
  9. import os
  10. import re
  11. import subprocess
  12. from dataclasses import dataclass, field
  13. from enum import Enum
  14. from pathlib import Path
  15. class InstallState(Enum):
  16. """Zustand der bestehenden Installation."""
  17. NOT_FOUND = "not_found"
  18. SAME_VERSION = "same_version"
  19. NEWER_AVAILABLE = "newer_available" # Installer ist neuer → Update
  20. OLDER_AVAILABLE = "older_available" # Installer ist aelter → Downgrade
  21. @dataclass
  22. class ExistingInstallation:
  23. """Informationen ueber eine bestehende Installation."""
  24. found: bool = False
  25. state: InstallState = InstallState.NOT_FOUND
  26. installed_version: str = ""
  27. install_path: str = ""
  28. source_path: str = ""
  29. service_name: str = ""
  30. service_active: bool = False
  31. service_enabled: bool = False
  32. # Erkannte Einstellungen
  33. hostname: str = ""
  34. username: str = ""
  35. autostart: bool = False
  36. install_service: bool = False
  37. auto_regist: bool = False
  38. nlp_backend: str = ""
  39. ollama_host: str = ""
  40. wifi_enabled: bool = False
  41. wifi_ssid: str = ""
  42. wifi_password: str = ""
  43. wifi_channel: int = 6
  44. ap_ip: str = "10.10.10.1"
  45. dhcp_range_start: str = "10.10.10.10"
  46. dhcp_range_end: str = "10.10.10.50"
  47. server_host: str = ""
  48. server_port: int = 2101
  49. room: str = ""
  50. alias: str = ""
  51. selected_plugins: list[str] = field(default_factory=list)
  52. def detect_installation(install_path: str, mode: str, installer_version: str) -> ExistingInstallation:
  53. """
  54. Erkennt eine bestehende Trixy-Installation.
  55. Args:
  56. install_path: Erwarteter Installationspfad (z.B. /home/pi/trixy)
  57. mode: Installationsmodus (server/client/standalone/config)
  58. installer_version: Version des Installers
  59. Returns:
  60. ExistingInstallation mit allen erkannten Informationen.
  61. """
  62. info = ExistingInstallation()
  63. source = f"{install_path}/source"
  64. # Pruefen ob source-Verzeichnis existiert
  65. if not os.path.isdir(source):
  66. return info
  67. # Version auslesen
  68. version_file = f"{source}/trixy_core/utils/version.py"
  69. if not os.path.isfile(version_file):
  70. # Eventuell alte Struktur (ohne source/ Unterverzeichnis)
  71. return info
  72. info.found = True
  73. info.install_path = install_path
  74. info.source_path = source
  75. info.installed_version = _parse_version_file(version_file)
  76. # Versionsvergleich
  77. info.state = _compare_versions(info.installed_version, installer_version)
  78. # Service-Status
  79. info.service_name = f"trixy-{mode}"
  80. info.service_active = _is_service_active(info.service_name)
  81. info.service_enabled = _is_service_enabled(info.service_name)
  82. info.install_service = info.service_enabled or info.service_active
  83. info.autostart = info.service_enabled
  84. # Auto-Regist aus Service-Datei lesen
  85. service_file = f"/etc/systemd/system/{info.service_name}.service"
  86. if os.path.isfile(service_file):
  87. _parse_service_file(service_file, info)
  88. # Hostname
  89. try:
  90. info.hostname = os.uname().nodename
  91. except Exception:
  92. pass
  93. # Username
  94. info.username = os.environ.get("SUDO_USER", os.environ.get("USER", "pi"))
  95. # Installierte Plugins erkennen
  96. plugins_dir = f"{source}/plugins"
  97. if os.path.isdir(plugins_dir):
  98. for entry in sorted(os.listdir(plugins_dir)):
  99. plugin_path = f"{plugins_dir}/{entry}"
  100. if os.path.isdir(plugin_path) and os.path.isfile(f"{plugin_path}/main.py"):
  101. # Plugin-Config lesen um enabled-Status zu pruefen
  102. config_file = f"{plugin_path}/config.json"
  103. if os.path.isfile(config_file):
  104. try:
  105. with open(config_file) as f:
  106. cfg = json.load(f)
  107. if cfg.get("enabled", False):
  108. info.selected_plugins.append(entry)
  109. except (json.JSONDecodeError, OSError):
  110. pass
  111. # WiFi-Konfiguration aus hostapd lesen
  112. _parse_hostapd(info)
  113. # DHCP-Konfiguration aus dnsmasq lesen
  114. _parse_dnsmasq(info)
  115. # Client-Konfiguration aus Config-Datei lesen
  116. if mode == "client":
  117. _parse_client_config(source, info)
  118. # NLP-Backend aus Plugin-Config lesen
  119. nlp_config = f"{source}/plugins/nlp_llm/config.json"
  120. if os.path.isfile(nlp_config):
  121. try:
  122. with open(nlp_config) as f:
  123. cfg = json.load(f)
  124. info.nlp_backend = cfg.get("backend", "")
  125. info.ollama_host = cfg.get("ollama_host", "")
  126. # Wenn ollama mit nicht-localhost Host → ollama_remote
  127. if info.nlp_backend == "ollama" and info.ollama_host and "localhost" not in info.ollama_host and "127.0.0.1" not in info.ollama_host:
  128. info.nlp_backend = "ollama_remote"
  129. except (json.JSONDecodeError, OSError):
  130. pass
  131. return info
  132. def _parse_version_file(path: str) -> str:
  133. """Liest die Version aus version.py."""
  134. try:
  135. with open(path) as f:
  136. content = f.read()
  137. major = re.search(r'^MAJOR:\s*int\s*=\s*(\d+)', content, re.MULTILINE)
  138. minor = re.search(r'^MINOR:\s*int\s*=\s*(\d+)', content, re.MULTILINE)
  139. revision = re.search(r'^REVISION:\s*int\s*=\s*(\d+)', content, re.MULTILINE)
  140. if major and minor and revision:
  141. return f"{major.group(1)}.{minor.group(1)}.{revision.group(1)}"
  142. except OSError:
  143. pass
  144. return ""
  145. def _compare_versions(installed: str, installer: str) -> InstallState:
  146. """Vergleicht zwei Versionsstrings (semver)."""
  147. if not installed:
  148. return InstallState.NOT_FOUND
  149. try:
  150. inst_parts = tuple(int(x) for x in installed.split("."))
  151. new_parts = tuple(int(x) for x in installer.split("."))
  152. except ValueError:
  153. return InstallState.SAME_VERSION
  154. if inst_parts == new_parts:
  155. return InstallState.SAME_VERSION
  156. elif inst_parts < new_parts:
  157. return InstallState.NEWER_AVAILABLE
  158. else:
  159. return InstallState.OLDER_AVAILABLE
  160. def _is_service_active(name: str) -> bool:
  161. """Prueft ob ein systemd-Service laeuft."""
  162. try:
  163. result = subprocess.run(
  164. ["systemctl", "is-active", f"{name}.service"],
  165. capture_output=True, text=True, timeout=5,
  166. )
  167. return result.stdout.strip() == "active"
  168. except Exception:
  169. return False
  170. def _is_service_enabled(name: str) -> bool:
  171. """Prueft ob ein systemd-Service beim Boot startet."""
  172. try:
  173. result = subprocess.run(
  174. ["systemctl", "is-enabled", f"{name}.service"],
  175. capture_output=True, text=True, timeout=5,
  176. )
  177. return result.stdout.strip() == "enabled"
  178. except Exception:
  179. return False
  180. def _parse_service_file(path: str, info: ExistingInstallation) -> None:
  181. """Liest Einstellungen aus der systemd-Service-Datei."""
  182. try:
  183. with open(path) as f:
  184. content = f.read()
  185. if "--auto-regist" in content:
  186. info.auto_regist = True
  187. except OSError:
  188. pass
  189. def _parse_hostapd(info: ExistingInstallation) -> None:
  190. """Liest WiFi-Einstellungen aus hostapd.conf."""
  191. path = "/etc/hostapd/hostapd.conf"
  192. if not os.path.isfile(path):
  193. return
  194. info.wifi_enabled = True
  195. try:
  196. with open(path) as f:
  197. for line in f:
  198. line = line.strip()
  199. if line.startswith("ssid="):
  200. info.wifi_ssid = line.split("=", 1)[1]
  201. elif line.startswith("wpa_passphrase="):
  202. info.wifi_password = line.split("=", 1)[1]
  203. elif line.startswith("channel="):
  204. try:
  205. info.wifi_channel = int(line.split("=", 1)[1])
  206. except ValueError:
  207. pass
  208. except OSError:
  209. pass
  210. def _parse_dnsmasq(info: ExistingInstallation) -> None:
  211. """Liest DHCP-Einstellungen aus dnsmasq.conf."""
  212. path = "/etc/dnsmasq.conf"
  213. if not os.path.isfile(path):
  214. return
  215. try:
  216. with open(path) as f:
  217. for line in f:
  218. line = line.strip()
  219. if line.startswith("dhcp-range="):
  220. # dhcp-range=10.10.10.10,10.10.10.50,255.255.255.0,12h
  221. parts = line.split("=", 1)[1].split(",")
  222. if len(parts) >= 2:
  223. info.dhcp_range_start = parts[0]
  224. info.dhcp_range_end = parts[1]
  225. elif line.startswith("address="):
  226. # address=/trixyone_srv/10.10.10.1
  227. parts = line.split("/")
  228. if len(parts) >= 3:
  229. info.ap_ip = parts[-1]
  230. except OSError:
  231. pass
  232. def _parse_client_config(source: str, info: ExistingInstallation) -> None:
  233. """Liest Client-Einstellungen aus der Config-Datei."""
  234. config_file = f"{source}/config/client_config.json"
  235. if not os.path.isfile(config_file):
  236. return
  237. try:
  238. with open(config_file) as f:
  239. cfg = json.load(f)
  240. info.server_host = cfg.get("server_host", info.server_host)
  241. info.server_port = cfg.get("server_port", info.server_port)
  242. info.room = cfg.get("room", info.room)
  243. info.alias = cfg.get("alias", info.alias)
  244. except (json.JSONDecodeError, OSError):
  245. pass
  246. def apply_existing_to_config(existing: ExistingInstallation, config: "InstallerConfig") -> None:
  247. """
  248. Uebertraegt erkannte Einstellungen auf die InstallerConfig.
  249. So werden die TUI-Felder mit den aktuellen Werten vorbelegt.
  250. """
  251. from .config import InstallerConfig
  252. config.hostname = existing.hostname or config.hostname
  253. config.username = existing.username or config.username
  254. config.install_path = existing.install_path or config.install_path
  255. config.autostart = existing.autostart
  256. config.install_service = existing.install_service
  257. config.auto_regist = existing.auto_regist
  258. # WiFi
  259. if existing.wifi_enabled:
  260. config.wifi_enabled = True
  261. config.wifi_ssid = existing.wifi_ssid or config.wifi_ssid
  262. config.wifi_password = existing.wifi_password or config.wifi_password
  263. config.wifi_channel = existing.wifi_channel
  264. config.ap_ip = existing.ap_ip
  265. config.dhcp_range_start = existing.dhcp_range_start
  266. config.dhcp_range_end = existing.dhcp_range_end
  267. # Client
  268. if existing.server_host:
  269. config.server_host = existing.server_host
  270. if existing.server_port:
  271. config.server_port = existing.server_port
  272. if existing.room:
  273. config.room = existing.room
  274. if existing.alias:
  275. config.alias = existing.alias
  276. # Plugins
  277. # Plugins nur fuer server/standalone uebernehmen
  278. if existing.selected_plugins and config.mode in ("server", "standalone"):
  279. config.selected_plugins = list(existing.selected_plugins)
  280. # NLP Backend
  281. if existing.nlp_backend:
  282. config.nlp_backend = existing.nlp_backend
  283. if existing.ollama_host:
  284. config.ollama_host = existing.ollama_host