config_app.py 39 KB

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