config_app.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166
  1. # -*- coding: utf-8 -*-
  2. """
  3. Config-Tool Anwendung.
  4. Verbindet sich per TRXI-Protokoll mit einer laufenden Trixy-Instanz
  5. und stellt eine TUI fuer Remote-Verwaltung bereit.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import struct
  10. from pathlib import Path
  11. from typing import Any
  12. from trixy_core.network.protocol import (
  13. TrixyProtocol,
  14. ProtocolFlags,
  15. ProtocolMessage,
  16. MAGIC,
  17. MAGIC_LENGTH,
  18. COMMAND_HELLO,
  19. COMMAND_PING,
  20. COMMAND_PONG,
  21. )
  22. from trixy_core.network.encryption import TrixyEncryption
  23. from trixy_core.network.cmd.config_cmd import (
  24. ConfigConnect,
  25. ConfigConnectAccepted,
  26. ConfigConnectDenied,
  27. ConfigStatusRequest,
  28. ConfigStatusResponse,
  29. ConfigReadRequest,
  30. ConfigReadResponse,
  31. ConfigWriteRequest,
  32. ConfigWriteResponse,
  33. ConfigFieldOptionsRequest,
  34. ConfigFieldOptionsResponse,
  35. ConfigSatelliteListRequest,
  36. ConfigSatelliteListResponse,
  37. ConfigSatelliteActionRequest,
  38. ConfigSatelliteActionResponse,
  39. ConfigSatelliteDetailRequest,
  40. ConfigSatelliteDetailResponse,
  41. ConfigSatelliteConfigReadRequest,
  42. ConfigSatelliteConfigReadResponse,
  43. ConfigSatelliteConfigWriteRequest,
  44. ConfigSatelliteConfigWriteResponse,
  45. ConfigSatelliteAudioRequest,
  46. ConfigSatelliteAudioResponse,
  47. ConfigMicTestStartRequest,
  48. ConfigMicTestStartResponse,
  49. ConfigMicTestStopRequest,
  50. ConfigMicTestStopResponse,
  51. ConfigMicTestAudioData,
  52. ConfigPluginListRequest,
  53. ConfigPluginListResponse,
  54. ConfigPluginActionRequest,
  55. ConfigPluginActionResponse,
  56. ConfigPluginDetailRequest,
  57. ConfigPluginDetailResponse,
  58. ConfigPluginConfigWriteRequest,
  59. ConfigPluginConfigWriteResponse,
  60. ConfigScheduleListRequest,
  61. ConfigScheduleListResponse,
  62. ConfigScheduleActionRequest,
  63. ConfigScheduleActionResponse,
  64. ConfigScheduleDetailRequest,
  65. ConfigScheduleDetailResponse,
  66. ConfigScheduleSaveRequest,
  67. ConfigScheduleSaveResponse,
  68. ConfigScheduleFormMetaRequest,
  69. ConfigScheduleFormMetaResponse,
  70. ConfigTrainerListRequest,
  71. ConfigTrainerListResponse,
  72. ConfigTrainerDetailRequest,
  73. ConfigTrainerDetailResponse,
  74. ConfigTrainerSettingsWriteRequest,
  75. ConfigTrainerSettingsWriteResponse,
  76. ConfigTrainerValidateRequest,
  77. ConfigTrainerValidateResponse,
  78. ConfigTrainerStartRequest,
  79. ConfigTrainerStartResponse,
  80. ConfigTrainerPauseRequest,
  81. ConfigTrainerPauseResponse,
  82. ConfigTrainerResumeRequest,
  83. ConfigTrainerResumeResponse,
  84. ConfigTrainerStopRequest,
  85. ConfigTrainerStopResponse,
  86. ConfigTrainerProgressRequest,
  87. ConfigTrainerProgressResponse,
  88. ConfigTrainerActionRequest,
  89. ConfigTrainerActionResponse,
  90. )
  91. from trixy_core.utils.debug import pinfo, pdebug, perror
  92. from trixy_core.utils.version import VERSION_STRING
  93. def _load_refresh_interval() -> float | None:
  94. """Laedt das Refresh-Intervall fuer die Config-Tool TUI.
  95. Prioritaet:
  96. 1. Umgebungsvariable TRIXY_CONFIG_REFRESH_INTERVAL
  97. 2. config/config_tool_config.json → refresh_interval_seconds
  98. 3. None → Default aus TrixyTUI wird verwendet
  99. """
  100. import os as _os
  101. env_val = _os.environ.get("TRIXY_CONFIG_REFRESH_INTERVAL")
  102. if env_val:
  103. try:
  104. val = float(env_val)
  105. if val >= 0.5:
  106. return val
  107. except ValueError:
  108. pass
  109. try:
  110. import json as _json
  111. from pathlib import Path as _Path
  112. # Mehrere Pfade probieren — abhaengig von wo das Config-Tool gestartet wird
  113. for path in [
  114. _Path("config/config_tool_config.json"),
  115. _Path(__file__).parent.parent / "config" / "config_tool_config.json",
  116. ]:
  117. if path.exists():
  118. with open(path, "r", encoding="utf-8") as f:
  119. cfg = _json.load(f)
  120. val = float(cfg.get("refresh_interval_seconds", 0))
  121. if val >= 0.5:
  122. return val
  123. break
  124. except Exception:
  125. pass
  126. return None
  127. class ConfigConnection:
  128. """
  129. Netzwerk-Verbindung zum ConfigListener einer laufenden Instanz.
  130. Verwaltet den Handshake, Request/Response-Korrelation und
  131. bietet High-Level-API fuer Status- und Config-Abfragen.
  132. """
  133. def __init__(
  134. self,
  135. host: str = "localhost",
  136. port: int = 2105,
  137. encryption_key_path: str = "certs/encryption.key",
  138. timeout: float = 10.0,
  139. ) -> None:
  140. self._host = host
  141. self._port = port
  142. self._encryption_key_path = encryption_key_path
  143. self._timeout = timeout
  144. self._reader: asyncio.StreamReader | None = None
  145. self._writer: asyncio.StreamWriter | None = None
  146. self._protocol = TrixyProtocol()
  147. self._connected = False
  148. self._receive_task: asyncio.Task | None = None
  149. # Request/Response-Korrelation
  150. self._pending: dict[str, asyncio.Future] = {}
  151. self._send_lock = asyncio.Lock()
  152. # Verbindungsinformationen
  153. self.instance_type: str = ""
  154. self.instance_version: str = ""
  155. self.hostname: str = ""
  156. # Mikrofon-Test Callback
  157. self._mic_test_callback: Any = None
  158. @property
  159. def is_connected(self) -> bool:
  160. """Ist die Verbindung aktiv?"""
  161. return self._connected
  162. @property
  163. def connection_info(self) -> str:
  164. """Verbindungsinformation fuer Anzeige."""
  165. return f"{self.instance_type}@{self._host}:{self._port}"
  166. async def connect(self) -> None:
  167. """
  168. Baut die Verbindung zum ConfigListener auf.
  169. Raises:
  170. ConnectionError: Bei Verbindungsfehlern
  171. """
  172. # Verschluesselung laden
  173. key_path = Path(self._encryption_key_path)
  174. if key_path.exists():
  175. try:
  176. encryption = TrixyEncryption.load_key(key_path)
  177. self._protocol.set_encryption(encryption)
  178. except Exception as e:
  179. pdebug(f"Verschluesselung nicht verfuegbar: {e}")
  180. # TCP-Verbindung aufbauen
  181. try:
  182. self._reader, self._writer = await asyncio.wait_for(
  183. asyncio.open_connection(self._host, self._port),
  184. timeout=self._timeout,
  185. )
  186. except (OSError, asyncio.TimeoutError) as e:
  187. raise ConnectionError(
  188. f"Verbindung zu {self._host}:{self._port} fehlgeschlagen: {e}"
  189. ) from e
  190. # HELLO senden
  191. self._writer.write(COMMAND_HELLO)
  192. await self._writer.drain()
  193. # ConfigConnect senden
  194. connect_msg = ConfigConnect(tool_version=VERSION_STRING)
  195. await self._send_message(connect_msg)
  196. # Antwort lesen
  197. try:
  198. response = await asyncio.wait_for(
  199. self._read_message(),
  200. timeout=self._timeout,
  201. )
  202. except asyncio.TimeoutError:
  203. await self._close()
  204. raise ConnectionError("Timeout beim Handshake")
  205. if response is None:
  206. await self._close()
  207. raise ConnectionError("Keine Antwort beim Handshake")
  208. if response.class_name == "ConfigConnectDenied":
  209. reason = ""
  210. if hasattr(response.data, "reason"):
  211. reason = response.data.reason
  212. elif isinstance(response.data, dict):
  213. reason = response.data.get("reason", "")
  214. await self._close()
  215. raise ConnectionError(f"Verbindung abgelehnt: {reason}")
  216. if response.class_name == "ConfigConnectAccepted":
  217. data = response.data
  218. if hasattr(data, "instance_type"):
  219. self.instance_type = data.instance_type
  220. self.instance_version = data.instance_version
  221. self.hostname = data.hostname
  222. elif isinstance(data, dict):
  223. self.instance_type = data.get("instance_type", "")
  224. self.instance_version = data.get("instance_version", "")
  225. self.hostname = data.get("hostname", "")
  226. self._connected = True
  227. # Empfangsschleife starten
  228. self._receive_task = asyncio.create_task(self._receive_loop())
  229. pinfo(f"Verbunden mit {self.connection_info}")
  230. async def disconnect(self) -> None:
  231. """Trennt die Verbindung."""
  232. self._connected = False
  233. if self._receive_task:
  234. self._receive_task.cancel()
  235. self._receive_task = None
  236. await self._close()
  237. async def request_status(
  238. self, include_network: bool = True, include_disk: bool = True
  239. ) -> ConfigStatusResponse | None:
  240. """
  241. Fordert System-Metriken an.
  242. Returns:
  243. ConfigStatusResponse oder None bei Fehler
  244. """
  245. request = ConfigStatusRequest(
  246. include_network=include_network,
  247. include_disk=include_disk,
  248. )
  249. response = await self._request(request)
  250. if response and isinstance(response.data, ConfigStatusResponse):
  251. return response.data
  252. # Fallback: Daten aus ProtocolMessage extrahieren
  253. if response:
  254. return self._extract_response(response, ConfigStatusResponse)
  255. return None
  256. async def request_config(
  257. self, config_name: str = "", section: str = ""
  258. ) -> dict | None:
  259. """
  260. Liest die Remote-Konfiguration.
  261. Returns:
  262. Konfigurationsdaten als Dictionary oder None
  263. """
  264. request = ConfigReadRequest(
  265. config_name=config_name,
  266. section=section,
  267. )
  268. response = await self._request(request)
  269. if response:
  270. data = response.data
  271. if isinstance(data, ConfigReadResponse):
  272. return data.data
  273. elif isinstance(data, dict):
  274. return data.get("data", data)
  275. elif hasattr(data, "data"):
  276. return data.data
  277. return None
  278. async def write_config(
  279. self, config_name: str, key_path: str, value: Any
  280. ) -> ConfigWriteResponse | None:
  281. """
  282. Schreibt einen Konfigurationswert.
  283. Returns:
  284. ConfigWriteResponse oder None bei Fehler
  285. """
  286. request = ConfigWriteRequest(
  287. config_name=config_name,
  288. key_path=key_path,
  289. value=value,
  290. )
  291. response = await self._request(request)
  292. if response and isinstance(response.data, ConfigWriteResponse):
  293. return response.data
  294. if response:
  295. return self._extract_response(response, ConfigWriteResponse)
  296. return None
  297. async def request_satellites(self) -> ConfigSatelliteListResponse | None:
  298. """
  299. Fordert die Satellite-Liste vom Server an.
  300. Returns:
  301. ConfigSatelliteListResponse oder None bei Fehler
  302. """
  303. request = ConfigSatelliteListRequest()
  304. response = await self._request(request)
  305. if response and isinstance(response.data, ConfigSatelliteListResponse):
  306. return response.data
  307. if response:
  308. return self._extract_response(response, ConfigSatelliteListResponse)
  309. return None
  310. async def satellite_action(
  311. self,
  312. action: str,
  313. satellite_id: str = "",
  314. mac_address: str = "",
  315. timeout: int = 60,
  316. ) -> ConfigSatelliteActionResponse | None:
  317. """
  318. Fuehrt eine Satellite-Aktion aus.
  319. Args:
  320. action: Aktions-Typ
  321. satellite_id: Betroffener Satellite
  322. mac_address: MAC-Adresse (alternativ)
  323. timeout: Timeout fuer Registration
  324. Returns:
  325. ConfigSatelliteActionResponse oder None
  326. """
  327. request = ConfigSatelliteActionRequest(
  328. action=action,
  329. satellite_id=satellite_id,
  330. mac_address=mac_address,
  331. timeout=timeout,
  332. )
  333. response = await self._request(request)
  334. if response and isinstance(response.data, ConfigSatelliteActionResponse):
  335. return response.data
  336. if response:
  337. return self._extract_response(response, ConfigSatelliteActionResponse)
  338. return None
  339. async def request_satellite_detail(
  340. self, satellite_id: str
  341. ) -> ConfigSatelliteDetailResponse | None:
  342. """
  343. Fordert detaillierte Satellite-Informationen an.
  344. Args:
  345. satellite_id: ID des Satellites
  346. Returns:
  347. ConfigSatelliteDetailResponse oder None bei Fehler
  348. """
  349. request = ConfigSatelliteDetailRequest(satellite_id=satellite_id)
  350. response = await self._request(request)
  351. if response and isinstance(response.data, ConfigSatelliteDetailResponse):
  352. return response.data
  353. if response:
  354. return self._extract_response(response, ConfigSatelliteDetailResponse)
  355. return None
  356. async def request_satellite_config(
  357. self, satellite_id: str, config_name: str = "", section: str = ""
  358. ) -> dict | None:
  359. """
  360. Liest die Konfiguration eines Satellites (via Server-Proxy).
  361. Returns:
  362. Konfigurationsdaten als Dictionary oder None
  363. """
  364. request = ConfigSatelliteConfigReadRequest(
  365. satellite_id=satellite_id,
  366. config_name=config_name,
  367. section=section,
  368. )
  369. response = await self._request(request)
  370. if response:
  371. data = response.data
  372. if isinstance(data, ConfigSatelliteConfigReadResponse):
  373. if data.error:
  374. return None
  375. return data.data
  376. extracted = self._extract_response(response, ConfigSatelliteConfigReadResponse)
  377. if isinstance(extracted, ConfigSatelliteConfigReadResponse):
  378. if extracted.error:
  379. return None
  380. return extracted.data
  381. if isinstance(extracted, dict):
  382. return extracted.get("data", extracted)
  383. return None
  384. async def write_satellite_config(
  385. self, satellite_id: str, key_path: str, value: Any, config_name: str = ""
  386. ) -> ConfigSatelliteConfigWriteResponse | None:
  387. """
  388. Schreibt einen Konfigurationswert auf einem Satellite (via Server-Proxy).
  389. Returns:
  390. ConfigSatelliteConfigWriteResponse oder None
  391. """
  392. request = ConfigSatelliteConfigWriteRequest(
  393. satellite_id=satellite_id,
  394. config_name=config_name,
  395. key_path=key_path,
  396. value=value,
  397. )
  398. response = await self._request(request)
  399. if response and isinstance(response.data, ConfigSatelliteConfigWriteResponse):
  400. return response.data
  401. if response:
  402. return self._extract_response(response, ConfigSatelliteConfigWriteResponse)
  403. return None
  404. async def request_satellite_audio(
  405. self,
  406. satellite_id: str,
  407. action: str = "query",
  408. target: str = "output",
  409. volume: int = 0,
  410. muted: bool = False,
  411. speakers: list | None = None,
  412. microphones: list | None = None,
  413. ) -> ConfigSatelliteAudioResponse | None:
  414. """
  415. Sendet eine Audio-Anfrage an einen Satellite (via Server-Proxy).
  416. Args:
  417. satellite_id: ID des Satellites
  418. action: "query" | "set_volume" | "set_mute" | "list_devices"
  419. target: "output" | "input"
  420. volume: Lautstaerke (0-100) bei action="set_volume"
  421. muted: Mute-Status bei action="set_mute"
  422. """
  423. request = ConfigSatelliteAudioRequest(
  424. satellite_id=satellite_id,
  425. action=action,
  426. target=target,
  427. volume=int(volume),
  428. muted=bool(muted),
  429. speakers=list(speakers or []),
  430. microphones=list(microphones or []),
  431. )
  432. response = await self._request(request)
  433. if response and isinstance(response.data, ConfigSatelliteAudioResponse):
  434. return response.data
  435. if response:
  436. return self._extract_response(response, ConfigSatelliteAudioResponse)
  437. return None
  438. async def request_field_options(
  439. self, field_sources: list[str]
  440. ) -> dict[str, list[tuple[str, str]]] | None:
  441. """
  442. Fordert dynamische Feld-Optionen vom Server an.
  443. Args:
  444. field_sources: Liste von Quell-Bezeichnern (z.B. ["wakeword_models"])
  445. Returns:
  446. Dictionary von Quell-Name -> Liste von (wert, anzeige) Tupeln
  447. """
  448. request = ConfigFieldOptionsRequest(field_sources=field_sources)
  449. response = await self._request(request)
  450. if response and isinstance(response.data, ConfigFieldOptionsResponse):
  451. return response.data.options
  452. if response:
  453. extracted = self._extract_response(response, ConfigFieldOptionsResponse)
  454. if isinstance(extracted, ConfigFieldOptionsResponse):
  455. return extracted.options
  456. if isinstance(extracted, dict):
  457. return extracted.get("options", {})
  458. return None
  459. async def start_mic_test(
  460. self, satellite_id: str, audio_callback: Any = None
  461. ) -> ConfigMicTestStartResponse | None:
  462. """
  463. Startet einen Mikrofon-Test fuer einen Satellite.
  464. Args:
  465. satellite_id: ID des Satellites
  466. audio_callback: Callback(audio_data: bytes) fuer empfangene Audio-Daten
  467. Returns:
  468. ConfigMicTestStartResponse oder None
  469. """
  470. self._mic_test_callback = audio_callback
  471. request = ConfigMicTestStartRequest(satellite_id=satellite_id)
  472. response = await self._request(request, timeout=15.0)
  473. if response and isinstance(response.data, ConfigMicTestStartResponse):
  474. if not response.data.success:
  475. self._mic_test_callback = None
  476. return response.data
  477. if response:
  478. extracted = self._extract_response(response, ConfigMicTestStartResponse)
  479. if isinstance(extracted, ConfigMicTestStartResponse) and not extracted.success:
  480. self._mic_test_callback = None
  481. return extracted
  482. self._mic_test_callback = None
  483. return None
  484. async def stop_mic_test(
  485. self, satellite_id: str
  486. ) -> ConfigMicTestStopResponse | None:
  487. """
  488. Stoppt einen laufenden Mikrofon-Test.
  489. Args:
  490. satellite_id: ID des Satellites
  491. Returns:
  492. ConfigMicTestStopResponse oder None
  493. """
  494. self._mic_test_callback = None
  495. request = ConfigMicTestStopRequest(satellite_id=satellite_id)
  496. response = await self._request(request)
  497. if response and isinstance(response.data, ConfigMicTestStopResponse):
  498. return response.data
  499. if response:
  500. return self._extract_response(response, ConfigMicTestStopResponse)
  501. return None
  502. # ── Plugin-API ──
  503. async def request_plugins(self) -> ConfigPluginListResponse | None:
  504. """
  505. Fordert die Plugin-Liste vom Server an.
  506. Returns:
  507. ConfigPluginListResponse oder None bei Fehler
  508. """
  509. request = ConfigPluginListRequest()
  510. response = await self._request(request)
  511. if response and isinstance(response.data, ConfigPluginListResponse):
  512. return response.data
  513. if response:
  514. return self._extract_response(response, ConfigPluginListResponse)
  515. return None
  516. async def plugin_action(
  517. self, action: str, plugin_name: str, params: dict | None = None,
  518. ) -> ConfigPluginActionResponse | None:
  519. """
  520. Fuehrt eine Plugin-Aktion aus.
  521. Standard-Aktionen: enable, disable, reload.
  522. Plugin-spezifische Aktionen werden an plugin.handle_config_action() delegiert.
  523. Returns:
  524. ConfigPluginActionResponse oder None
  525. """
  526. request = ConfigPluginActionRequest(
  527. action=action,
  528. plugin_name=plugin_name,
  529. params=params or {},
  530. )
  531. response = await self._request(request)
  532. if response and isinstance(response.data, ConfigPluginActionResponse):
  533. return response.data
  534. if response:
  535. return self._extract_response(response, ConfigPluginActionResponse)
  536. return None
  537. async def request_plugin_detail(
  538. self, plugin_name: str
  539. ) -> ConfigPluginDetailResponse | None:
  540. """
  541. Fordert detaillierte Plugin-Informationen an.
  542. Returns:
  543. ConfigPluginDetailResponse oder None
  544. """
  545. request = ConfigPluginDetailRequest(plugin_name=plugin_name)
  546. response = await self._request(request)
  547. if response and isinstance(response.data, ConfigPluginDetailResponse):
  548. return response.data
  549. if response:
  550. return self._extract_response(response, ConfigPluginDetailResponse)
  551. return None
  552. async def write_plugin_config(
  553. self, plugin_name: str, key_path: str, value: Any
  554. ) -> ConfigPluginConfigWriteResponse | None:
  555. """
  556. Schreibt einen Konfigurationswert fuer ein Plugin.
  557. Returns:
  558. ConfigPluginConfigWriteResponse oder None
  559. """
  560. request = ConfigPluginConfigWriteRequest(
  561. plugin_name=plugin_name,
  562. key_path=key_path,
  563. value=value,
  564. )
  565. response = await self._request(request)
  566. if response and isinstance(response.data, ConfigPluginConfigWriteResponse):
  567. return response.data
  568. if response:
  569. return self._extract_response(response, ConfigPluginConfigWriteResponse)
  570. return None
  571. # ── Schedule-API ──
  572. async def request_schedule_jobs(self) -> ConfigScheduleListResponse | None:
  573. """Fordert die Schedule-Job-Liste an."""
  574. request = ConfigScheduleListRequest()
  575. response = await self._request(request)
  576. if response and isinstance(response.data, ConfigScheduleListResponse):
  577. return response.data
  578. if response:
  579. return self._extract_response(response, ConfigScheduleListResponse)
  580. return None
  581. async def schedule_action(
  582. self, action: str, job_id: str
  583. ) -> ConfigScheduleActionResponse | None:
  584. """Fuehrt eine Schedule-Job-Aktion aus (enable/disable/delete/run_now)."""
  585. request = ConfigScheduleActionRequest(action=action, job_id=job_id)
  586. response = await self._request(request)
  587. if response and isinstance(response.data, ConfigScheduleActionResponse):
  588. return response.data
  589. if response:
  590. return self._extract_response(response, ConfigScheduleActionResponse)
  591. return None
  592. async def request_schedule_detail(
  593. self, job_id: str
  594. ) -> ConfigScheduleDetailResponse | None:
  595. """Fordert detaillierte Job-Informationen an."""
  596. request = ConfigScheduleDetailRequest(job_id=job_id)
  597. response = await self._request(request)
  598. if response and isinstance(response.data, ConfigScheduleDetailResponse):
  599. return response.data
  600. if response:
  601. return self._extract_response(response, ConfigScheduleDetailResponse)
  602. return None
  603. async def save_schedule_job(
  604. self,
  605. job_id: str,
  606. name: str,
  607. description: str,
  608. trigger: dict,
  609. conditions: list[dict],
  610. actions: list[dict],
  611. config: dict,
  612. ) -> ConfigScheduleSaveResponse | None:
  613. """Erstellt oder aktualisiert einen Schedule-Job."""
  614. request = ConfigScheduleSaveRequest(
  615. job_id=job_id,
  616. name=name,
  617. description=description,
  618. trigger=trigger,
  619. conditions=conditions,
  620. actions=actions,
  621. config=config,
  622. )
  623. response = await self._request(request)
  624. if response and isinstance(response.data, ConfigScheduleSaveResponse):
  625. return response.data
  626. if response:
  627. return self._extract_response(response, ConfigScheduleSaveResponse)
  628. return None
  629. async def request_schedule_form_meta(self) -> ConfigScheduleFormMetaResponse | None:
  630. """Fordert Formular-Metadaten fuer Scheduler-Komponenten an."""
  631. request = ConfigScheduleFormMetaRequest()
  632. response = await self._request(request)
  633. if response and isinstance(response.data, ConfigScheduleFormMetaResponse):
  634. return response.data
  635. if response:
  636. return self._extract_response(response, ConfigScheduleFormMetaResponse)
  637. return None
  638. # ── Trainer-API ──
  639. async def request_trainers(self) -> ConfigTrainerListResponse | None:
  640. """Fordert die Trainer-Liste an."""
  641. request = ConfigTrainerListRequest()
  642. response = await self._request(request)
  643. if response and isinstance(response.data, ConfigTrainerListResponse):
  644. return response.data
  645. if response:
  646. return self._extract_response(response, ConfigTrainerListResponse)
  647. return None
  648. async def request_trainer_detail(
  649. self, trainer_id: str
  650. ) -> ConfigTrainerDetailResponse | None:
  651. """Fordert detaillierte Trainer-Informationen an."""
  652. request = ConfigTrainerDetailRequest(trainer_id=trainer_id)
  653. response = await self._request(request)
  654. if response and isinstance(response.data, ConfigTrainerDetailResponse):
  655. return response.data
  656. if response:
  657. return self._extract_response(response, ConfigTrainerDetailResponse)
  658. return None
  659. async def write_trainer_settings(
  660. self, trainer_id: str, settings: dict
  661. ) -> ConfigTrainerSettingsWriteResponse | None:
  662. """Schreibt Trainer-Einstellungen."""
  663. request = ConfigTrainerSettingsWriteRequest(
  664. trainer_id=trainer_id, settings=settings,
  665. )
  666. response = await self._request(request)
  667. if response and isinstance(response.data, ConfigTrainerSettingsWriteResponse):
  668. return response.data
  669. if response:
  670. return self._extract_response(response, ConfigTrainerSettingsWriteResponse)
  671. return None
  672. async def validate_trainer(
  673. self, trainer_id: str
  674. ) -> ConfigTrainerValidateResponse | None:
  675. """Validiert einen Trainer."""
  676. request = ConfigTrainerValidateRequest(trainer_id=trainer_id)
  677. response = await self._request(request)
  678. if response and isinstance(response.data, ConfigTrainerValidateResponse):
  679. return response.data
  680. if response:
  681. return self._extract_response(response, ConfigTrainerValidateResponse)
  682. return None
  683. async def start_training(
  684. self, trainer_id: str
  685. ) -> ConfigTrainerStartResponse | None:
  686. """Startet ein Training."""
  687. request = ConfigTrainerStartRequest(trainer_id=trainer_id)
  688. response = await self._request(request, timeout=30.0)
  689. if response and isinstance(response.data, ConfigTrainerStartResponse):
  690. return response.data
  691. if response:
  692. return self._extract_response(response, ConfigTrainerStartResponse)
  693. return None
  694. async def pause_training(self) -> ConfigTrainerPauseResponse | None:
  695. """Pausiert das laufende Training."""
  696. request = ConfigTrainerPauseRequest()
  697. response = await self._request(request)
  698. if response and isinstance(response.data, ConfigTrainerPauseResponse):
  699. return response.data
  700. if response:
  701. return self._extract_response(response, ConfigTrainerPauseResponse)
  702. return None
  703. async def resume_training(self) -> ConfigTrainerResumeResponse | None:
  704. """Setzt ein pausiertes Training fort."""
  705. request = ConfigTrainerResumeRequest()
  706. response = await self._request(request)
  707. if response and isinstance(response.data, ConfigTrainerResumeResponse):
  708. return response.data
  709. if response:
  710. return self._extract_response(response, ConfigTrainerResumeResponse)
  711. return None
  712. async def stop_training(self) -> ConfigTrainerStopResponse | None:
  713. """Stoppt das laufende Training."""
  714. request = ConfigTrainerStopRequest()
  715. response = await self._request(request)
  716. if response and isinstance(response.data, ConfigTrainerStopResponse):
  717. return response.data
  718. if response:
  719. return self._extract_response(response, ConfigTrainerStopResponse)
  720. return None
  721. async def request_training_progress(self) -> ConfigTrainerProgressResponse | None:
  722. """Fordert den aktuellen Trainings-Fortschritt an."""
  723. request = ConfigTrainerProgressRequest()
  724. response = await self._request(request)
  725. if response and isinstance(response.data, ConfigTrainerProgressResponse):
  726. return response.data
  727. if response:
  728. return self._extract_response(response, ConfigTrainerProgressResponse)
  729. return None
  730. async def execute_trainer_action(
  731. self, trainer_id: str, action: str, params: dict | None = None
  732. ) -> ConfigTrainerActionResponse | None:
  733. """Fuehrt eine Trainer-Aktion aus."""
  734. request = ConfigTrainerActionRequest(
  735. trainer_id=trainer_id, action=action, params=params or {},
  736. )
  737. response = await self._request(request, timeout=120.0)
  738. if response and isinstance(response.data, ConfigTrainerActionResponse):
  739. return response.data
  740. if response:
  741. return self._extract_response(response, ConfigTrainerActionResponse)
  742. return None
  743. def _extract_response(self, message: ProtocolMessage, cls: type) -> Any:
  744. """Extrahiert eine typisierte Response aus einer ProtocolMessage."""
  745. data = message.data
  746. if isinstance(data, cls):
  747. return data
  748. if isinstance(data, dict):
  749. try:
  750. return cls(**{
  751. k: v for k, v in data.items()
  752. if k in cls.__dataclass_fields__
  753. })
  754. except Exception:
  755. pass
  756. if hasattr(data, "__dict__"):
  757. try:
  758. return cls(**{
  759. k: v for k, v in data.__dict__.items()
  760. if k in cls.__dataclass_fields__
  761. })
  762. except Exception:
  763. pass
  764. return data
  765. async def _request(
  766. self, message: object, timeout: float = 10.0
  767. ) -> ProtocolMessage | None:
  768. """
  769. Sendet eine Anfrage und wartet auf die Antwort.
  770. Nutzt message_id fuer Request/Response-Korrelation.
  771. """
  772. if not self._connected:
  773. return None
  774. message_id = getattr(message, "message_id", "")
  775. # Future fuer Antwort erstellen
  776. future: asyncio.Future[ProtocolMessage] = asyncio.get_event_loop().create_future()
  777. self._pending[message_id] = future
  778. try:
  779. await self._send_message(message)
  780. return await asyncio.wait_for(future, timeout=timeout)
  781. except asyncio.TimeoutError:
  782. return None
  783. finally:
  784. self._pending.pop(message_id, None)
  785. async def _receive_loop(self) -> None:
  786. """Empfaengt Nachrichten und loest Pending-Futures auf."""
  787. while self._connected:
  788. try:
  789. message = await self._read_message()
  790. if message is None:
  791. break
  792. # PING beantworten
  793. if message.class_name == "TRXIPING":
  794. await self._send_pong()
  795. continue
  796. # Pong ignorieren
  797. if message.class_name == "TRXIPONG":
  798. continue
  799. # Mikrofon-Test Audio-Daten (unsolicited)
  800. if message.class_name == "ConfigMicTestAudioData":
  801. if self._mic_test_callback:
  802. data = message.data
  803. audio = b""
  804. if hasattr(data, "audio_data"):
  805. audio = data.audio_data
  806. elif isinstance(data, dict):
  807. audio = data.get("audio_data", b"")
  808. if audio:
  809. try:
  810. self._mic_test_callback(audio)
  811. except Exception:
  812. pass
  813. continue
  814. # Message-ID extrahieren fuer Korrelation
  815. msg_id = ""
  816. data = message.data
  817. if hasattr(data, "message_id"):
  818. msg_id = data.message_id
  819. elif isinstance(data, dict):
  820. msg_id = data.get("message_id", "")
  821. # Pending Future aufloesen
  822. if msg_id and msg_id in self._pending:
  823. future = self._pending.pop(msg_id)
  824. if not future.done():
  825. future.set_result(message)
  826. elif self._pending:
  827. # Fallback: Erstes Pending aufloesen (wenn keine ID)
  828. first_key = next(iter(self._pending))
  829. future = self._pending.pop(first_key)
  830. if not future.done():
  831. future.set_result(message)
  832. except asyncio.CancelledError:
  833. break
  834. except Exception as e:
  835. if self._connected:
  836. perror(f"Config-Empfangsfehler: {e}")
  837. break
  838. self._connected = False
  839. async def _read_message(self) -> ProtocolMessage | None:
  840. """Liest eine vollstaendige Protokoll-Nachricht."""
  841. if not self._reader:
  842. return None
  843. try:
  844. # Prüfe auf Hard-coded Befehle (8 bytes)
  845. peek_data = await self._reader.readexactly(8)
  846. if self._protocol.is_hard_command(peek_data):
  847. return self._protocol.deserialize(peek_data)
  848. # Normaler Header
  849. header_size = MAGIC_LENGTH + 2 + 8 + 4 + 16 + 2
  850. remaining_header = await self._reader.readexactly(header_size - 8)
  851. header = peek_data + remaining_header
  852. if header[:MAGIC_LENGTH] != MAGIC:
  853. perror(f"Ungueltige Magic: {header[:MAGIC_LENGTH]}")
  854. return None
  855. class_name_length = struct.unpack(">H", header[34:36])[0]
  856. class_name = await self._reader.readexactly(class_name_length)
  857. data_length_bytes = await self._reader.readexactly(4)
  858. data_length = struct.unpack(">I", data_length_bytes)[0]
  859. data = await self._reader.readexactly(data_length) if data_length > 0 else b""
  860. full_message = header + class_name + data_length_bytes + data
  861. return self._protocol.deserialize(full_message)
  862. except (ConnectionResetError, asyncio.IncompleteReadError):
  863. return None
  864. except Exception as e:
  865. perror(f"Fehler beim Lesen: {e}")
  866. return None
  867. async def _send_message(
  868. self, message: object, flags: ProtocolFlags = ProtocolFlags.PICKLE
  869. ) -> bool:
  870. """Sendet eine Nachricht (thread-safe via Lock)."""
  871. if not self._writer:
  872. return False
  873. try:
  874. data = self._protocol.serialize(message, flags)
  875. async with self._send_lock:
  876. self._writer.write(data)
  877. await self._writer.drain()
  878. return True
  879. except Exception as e:
  880. perror(f"Sendefehler: {e}")
  881. return False
  882. async def _send_pong(self) -> None:
  883. """Sendet einen Pong."""
  884. if self._writer:
  885. try:
  886. self._writer.write(COMMAND_PONG)
  887. await self._writer.drain()
  888. except Exception:
  889. pass
  890. async def _close(self) -> None:
  891. """Schliesst die Verbindung."""
  892. if self._writer:
  893. try:
  894. self._writer.close()
  895. await self._writer.wait_closed()
  896. except Exception:
  897. pass
  898. self._writer = None
  899. self._reader = None
  900. class ConfigApplication:
  901. """
  902. Anwendung fuer das Config-Tool.
  903. Baut eine Verbindung zu einer laufenden Trixy-Instanz auf
  904. und startet die Textual-TUI fuer Remote-Verwaltung.
  905. Hinweis: Erbt NICHT von IApplication, da die Hauptschleife
  906. von Textual gesteuert wird (nicht von asyncio.Event.wait).
  907. """
  908. def __init__(
  909. self,
  910. host: str = "localhost",
  911. port: int = 2105,
  912. encryption_key_path: str = "certs/encryption.key",
  913. config_path: str = "config/config_tool_config.json",
  914. ) -> None:
  915. self._host = host
  916. self._port = port
  917. self._encryption_key_path = encryption_key_path
  918. self._config_path = config_path
  919. self._connection: ConfigConnection | None = None
  920. async def run(self) -> None:
  921. """Startet die Config-Tool-Anwendung."""
  922. # Verbindung aufbauen
  923. self._connection = ConfigConnection(
  924. host=self._host,
  925. port=self._port,
  926. encryption_key_path=self._encryption_key_path,
  927. )
  928. try:
  929. await self._connection.connect()
  930. except ConnectionError as e:
  931. perror(str(e))
  932. raise
  933. # TUI-Views erstellen
  934. from trixy_core.tui.views.health_view import HealthView
  935. from trixy_core.tui.views.config_view import ConfigView
  936. from trixy_core.tui.views.log_view import LogView
  937. from trixy_core.tui.views.sat_info_view import SatInfoView
  938. from trixy_core.tui.views.sat_connection_view import SatConnectionView
  939. from trixy_core.tui.views.sat_conversation_view import SatConversationView
  940. from trixy_core.tui.views.sat_config_view import SatConfigView
  941. from trixy_core.tui.views.sat_updates_view import SatUpdatesView
  942. from trixy_core.tui.views.sat_audio_view import SatAudioView
  943. from trixy_core.tui.views.plugins_view import PluginsView
  944. from trixy_core.tui.views.plugin_info_view import PluginInfoView
  945. from trixy_core.tui.views.plugin_config_view import PluginConfigView
  946. from trixy_core.tui.views.schedule_view import ScheduleView
  947. from trixy_core.tui.views.schedule_info_view import ScheduleInfoView
  948. from trixy_core.tui.views.schedule_trigger_view import ScheduleTriggerView
  949. from trixy_core.tui.views.schedule_condition_view import ScheduleConditionView
  950. from trixy_core.tui.views.schedule_action_view import ScheduleActionView
  951. from trixy_core.tui.views.trainer_view import TrainerView
  952. from trixy_core.tui.views.trainer_info_view import TrainerInfoView
  953. from trixy_core.tui.views.trainer_settings_view import TrainerSettingsView
  954. from trixy_core.tui.views.trainer_dataset_view import TrainerDatasetView
  955. from trixy_core.tui.views.trainer_optional_view import TrainerOptionalView
  956. from trixy_core.tui.views.trainer_validate_view import TrainerValidateView
  957. from trixy_core.tui.views.trainer_training_view import TrainerTrainingView
  958. conn = self._connection
  959. views = [
  960. HealthView(connection=conn),
  961. ConfigView(connection=conn),
  962. LogView(connection=conn),
  963. ]
  964. # Satellite-SubViews (fuer beide Modi benoetigt)
  965. sat_subviews = [
  966. SatInfoView(connection=conn),
  967. SatConnectionView(connection=conn),
  968. SatConversationView(connection=conn),
  969. SatConfigView(connection=conn),
  970. SatUpdatesView(connection=conn),
  971. SatAudioView(connection=conn),
  972. ]
  973. # Plugin-SubViews
  974. plugin_subviews = [
  975. PluginInfoView(connection=conn),
  976. PluginConfigView(connection=conn),
  977. ]
  978. # Schedule-SubViews
  979. schedule_subviews = [
  980. ScheduleInfoView(connection=conn),
  981. ScheduleTriggerView(connection=conn),
  982. ScheduleConditionView(connection=conn),
  983. ScheduleActionView(connection=conn),
  984. ]
  985. # Trainer-SubViews
  986. trainer_subviews = [
  987. TrainerInfoView(connection=conn),
  988. TrainerSettingsView(connection=conn),
  989. TrainerDatasetView(connection=conn),
  990. TrainerOptionalView(connection=conn),
  991. TrainerValidateView(connection=conn),
  992. TrainerTrainingView(connection=conn),
  993. ]
  994. # F3: Satellite-Views, F4: Plugin-Views je nach Instanz-Typ
  995. instance_type = self._connection.instance_type.lower()
  996. if instance_type == "server":
  997. from trixy_core.tui.views.satellites_view import SatellitesView
  998. views.insert(2, SatellitesView(connection=conn))
  999. views.insert(3, PluginsView(connection=conn))
  1000. views.insert(4, ScheduleView(connection=conn))
  1001. views.insert(5, TrainerView(connection=conn))
  1002. views.extend(sat_subviews)
  1003. views.extend(plugin_subviews)
  1004. views.extend(schedule_subviews)
  1005. views.extend(trainer_subviews)
  1006. elif instance_type in ("client", "satellite"):
  1007. # Client-Modus: SubViews direkt als Haupt-Navigation
  1008. views.extend(sat_subviews)
  1009. elif instance_type == "standalone":
  1010. # Standalone: Plugins und Scheduler verfuegbar, keine Satellites
  1011. views.insert(2, PluginsView(connection=conn))
  1012. views.insert(3, ScheduleView(connection=conn))
  1013. views.insert(4, TrainerView(connection=conn))
  1014. views.extend(plugin_subviews)
  1015. views.extend(schedule_subviews)
  1016. views.extend(trainer_subviews)
  1017. # TUI erstellen und Verbindung injizieren
  1018. from trixy_core.tui.app import TrixyTUI
  1019. # Refresh-Intervall aus Config-Datei oder ENV lesen
  1020. refresh_interval = _load_refresh_interval()
  1021. tui = TrixyTUI(
  1022. views=views,
  1023. connection_info=self._connection.connection_info,
  1024. refresh_interval=refresh_interval,
  1025. )
  1026. tui.set_connection(self._connection)
  1027. try:
  1028. await tui.run_async()
  1029. finally:
  1030. await self._connection.disconnect()
  1031. pinfo("Config-Tool beendet")