expression.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. # -*- coding: utf-8 -*-
  2. """
  3. Expression Condition - Evaluiert Python-Ausdrücke mit App-Zugriff.
  4. """
  5. from typing import Any, TYPE_CHECKING
  6. from trixy_core.scheduler.condition.base import Condition
  7. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  8. if TYPE_CHECKING:
  9. from trixy_core.application import TrixyApplication
  10. class ExpressionCondition(Condition):
  11. """
  12. Evaluiert einen Python-Ausdruck mit Zugriff auf App-Komponenten.
  13. Verfügbare Variablen im Ausdruck:
  14. - app: TrixyApplication Instanz
  15. - satellites: SatelliteManager
  16. - events: EventManager
  17. - plugins: PluginManager
  18. - config: ConfigManager
  19. - scheduler: Scheduler
  20. - context: Aktueller Condition-Kontext
  21. Beispiele:
  22. ```python
  23. # Satellite-Anzahl prüfen
  24. ExpressionCondition("satellites.count > 0")
  25. # Musik-Status prüfen
  26. ExpressionCondition("app.music_player.state == 'stopped'")
  27. # Kombinierte Bedingung
  28. ExpressionCondition(
  29. "satellites.count > 0 and events.listener_count('wakeword') > 0"
  30. )
  31. # Mit Kontext-Variable
  32. ExpressionCondition("context.get('temperature', 0) > 25")
  33. # Satellite in bestimmtem Raum verbunden
  34. ExpressionCondition("satellites.get_by_room('kitchen') is not None")
  35. # Plugin aktiviert
  36. ExpressionCondition("plugins.is_enabled('spotify')")
  37. ```
  38. """
  39. def __init__(
  40. self,
  41. expression: str,
  42. name: str | None = None,
  43. invert: bool = False,
  44. ):
  45. """
  46. Initialisiert die Condition.
  47. Args:
  48. expression: Python-Ausdruck der zu bool evaluiert wird
  49. name: Condition-Name
  50. invert: Ergebnis invertieren
  51. """
  52. super().__init__(name or f"expr:{expression[:30]}", invert)
  53. self._expression = expression
  54. @property
  55. def expression(self) -> str:
  56. """Python-Ausdruck."""
  57. return self._expression
  58. def _build_eval_context(self, context: dict) -> dict[str, Any]:
  59. """
  60. Erstellt den Evaluierungs-Kontext mit App-Zugriff.
  61. Args:
  62. context: Condition-Kontext
  63. Returns:
  64. Dictionary mit allen verfügbaren Variablen
  65. """
  66. eval_ctx: dict[str, Any] = {
  67. # Basis-Kontext
  68. "context": context,
  69. "ctx": context,
  70. # Python builtins (eingeschränkt)
  71. "len": len,
  72. "str": str,
  73. "int": int,
  74. "float": float,
  75. "bool": bool,
  76. "list": list,
  77. "dict": dict,
  78. "set": set,
  79. "tuple": tuple,
  80. "min": min,
  81. "max": max,
  82. "sum": sum,
  83. "abs": abs,
  84. "round": round,
  85. "any": any,
  86. "all": all,
  87. "sorted": sorted,
  88. "reversed": reversed,
  89. "enumerate": enumerate,
  90. "zip": zip,
  91. "range": range,
  92. "isinstance": isinstance,
  93. "hasattr": hasattr,
  94. "getattr": getattr,
  95. "None": None,
  96. "True": True,
  97. "False": False,
  98. }
  99. # App aus Kontext holen
  100. app = context.get("app") or context.get("application")
  101. if app:
  102. eval_ctx["app"] = app
  103. # Manager-Shortcuts
  104. if hasattr(app, "satellite_manager"):
  105. eval_ctx["satellites"] = app.satellite_manager
  106. eval_ctx["satellite_manager"] = app.satellite_manager
  107. if hasattr(app, "event_manager"):
  108. eval_ctx["events"] = app.event_manager
  109. eval_ctx["event_manager"] = app.event_manager
  110. if hasattr(app, "plugin_manager"):
  111. eval_ctx["plugins"] = app.plugin_manager
  112. eval_ctx["plugin_manager"] = app.plugin_manager
  113. if hasattr(app, "config_manager"):
  114. eval_ctx["config"] = app.config_manager
  115. eval_ctx["config_manager"] = app.config_manager
  116. if hasattr(app, "scheduler"):
  117. eval_ctx["scheduler"] = app.scheduler
  118. # Weitere App-Attribute
  119. if hasattr(app, "music_player"):
  120. eval_ctx["music"] = app.music_player
  121. eval_ctx["music_player"] = app.music_player
  122. if hasattr(app, "conversation_manager"):
  123. eval_ctx["conversations"] = app.conversation_manager
  124. if hasattr(app, "wakeword_service"):
  125. eval_ctx["wakeword"] = app.wakeword_service
  126. # Direkte Manager aus Kontext (falls ohne App)
  127. for key in ["satellite_manager", "event_manager", "plugin_manager",
  128. "config_manager", "scheduler"]:
  129. if key in context and key not in eval_ctx:
  130. eval_ctx[key] = context[key]
  131. # Kurznamen
  132. short_name = key.replace("_manager", "s").replace("_", "")
  133. if short_name not in eval_ctx:
  134. eval_ctx[short_name] = context[key]
  135. return eval_ctx
  136. def _evaluate(self, context: dict) -> bool:
  137. """Evaluiert den Ausdruck."""
  138. try:
  139. eval_ctx = self._build_eval_context(context)
  140. # Ausdruck evaluieren
  141. result = eval(self._expression, {"__builtins__": {}}, eval_ctx)
  142. return bool(result)
  143. except Exception as e:
  144. from trixy_core.utils.debug import perror
  145. perror(f"Expression-Fehler '{self._expression}': {e}")
  146. return False
  147. def to_dict(self) -> dict:
  148. """Konvertiert zu Dictionary."""
  149. return {
  150. **self._base_dict(),
  151. "expression": self._expression,
  152. }
  153. @classmethod
  154. def from_dict(cls, data: dict) -> "ExpressionCondition":
  155. """Erstellt Condition aus Dictionary."""
  156. return cls(
  157. expression=data["expression"],
  158. name=data.get("name"),
  159. invert=data.get("invert", False),
  160. )
  161. @classmethod
  162. def display_name(cls) -> str:
  163. """Anzeigename fuer die TUI."""
  164. return "Ausdruck"
  165. @classmethod
  166. def form_fields(cls) -> list[FormField]:
  167. """Gibt die Formular-Felder fuer die TUI zurueck."""
  168. return [
  169. FormField(
  170. name="expression", label="Python-Ausdruck",
  171. field_type=FieldType.TEXTAREA, required=True, lines=3,
  172. help_text="Python-Ausdruck der True/False zurueckgibt",
  173. placeholder="satellites.count > 0",
  174. ),
  175. FormField(
  176. name="invert", label="Invertieren",
  177. field_type=FieldType.CHECKBOX, default=False,
  178. ),
  179. ]
  180. @classmethod
  181. def validate_form(cls, data: dict) -> list[FormValidationError]:
  182. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  183. errors: list[FormValidationError] = []
  184. expr = data.get("expression", "").strip()
  185. if not expr:
  186. errors.append(FormValidationError("expression", "Python-Ausdruck ist erforderlich"))
  187. return errors
  188. def __repr__(self) -> str:
  189. return f"ExpressionCondition({self._expression!r})"
  190. class LambdaCondition(Condition):
  191. """
  192. Condition mit Lambda-Funktion.
  193. Für komplexere Logik die nicht in einen Einzeiler passt,
  194. aber trotzdem inline definiert werden soll.
  195. Beispiel:
  196. ```python
  197. LambdaCondition(
  198. lambda ctx: (
  199. ctx.get("satellites", {}).get("count", 0) > 0 and
  200. ctx.get("last_activity") is not None
  201. )
  202. )
  203. ```
  204. """
  205. def __init__(
  206. self,
  207. func: callable,
  208. name: str | None = None,
  209. invert: bool = False,
  210. ):
  211. """
  212. Initialisiert die Condition.
  213. Args:
  214. func: Lambda/Funktion die context → bool mapped
  215. name: Condition-Name
  216. invert: Ergebnis invertieren
  217. """
  218. super().__init__(name or "lambda", invert)
  219. self._func = func
  220. def _evaluate(self, context: dict) -> bool:
  221. """Ruft die Funktion auf."""
  222. try:
  223. result = self._func(context)
  224. return bool(result)
  225. except Exception as e:
  226. from trixy_core.utils.debug import perror
  227. perror(f"Lambda-Fehler: {e}")
  228. return False
  229. @classmethod
  230. def display_name(cls) -> str:
  231. """Anzeigename fuer die TUI."""
  232. return "Lambda-Bedingung"
  233. def to_dict(self) -> dict:
  234. """Konvertiert zu Dictionary (ohne Funktion)."""
  235. return {
  236. **self._base_dict(),
  237. # Lambda kann nicht serialisiert werden
  238. "func": "<lambda>",
  239. }
  240. # Shortcut-Funktionen für häufige Expressions
  241. def satellites_connected(min_count: int = 1) -> ExpressionCondition:
  242. """Condition: Mindestens X Satellites verbunden."""
  243. return ExpressionCondition(
  244. f"satellites.count >= {min_count}",
  245. name=f"satellites_min_{min_count}",
  246. )
  247. def satellite_in_room(room: str) -> ExpressionCondition:
  248. """Condition: Satellite in bestimmtem Raum verbunden."""
  249. return ExpressionCondition(
  250. f"satellites.get_by_room('{room}') is not None",
  251. name=f"satellite_in_{room}",
  252. )
  253. def plugin_enabled(plugin_name: str) -> ExpressionCondition:
  254. """Condition: Plugin ist aktiviert."""
  255. return ExpressionCondition(
  256. f"plugins.is_enabled('{plugin_name}')",
  257. name=f"plugin_{plugin_name}_enabled",
  258. )
  259. def music_playing() -> ExpressionCondition:
  260. """Condition: Musik wird abgespielt."""
  261. return ExpressionCondition(
  262. "music_player is not None and music_player.is_playing",
  263. name="music_playing",
  264. )
  265. def music_stopped() -> ExpressionCondition:
  266. """Condition: Musik ist gestoppt."""
  267. return ExpressionCondition(
  268. "music_player is None or not music_player.is_playing",
  269. name="music_stopped",
  270. )
  271. def conversation_active() -> ExpressionCondition:
  272. """Condition: Eine Konversation ist aktiv."""
  273. return ExpressionCondition(
  274. "conversations is not None and conversations.has_active_session()",
  275. name="conversation_active",
  276. )
  277. def no_conversation() -> ExpressionCondition:
  278. """Condition: Keine aktive Konversation."""
  279. return ExpressionCondition(
  280. "conversations is None or not conversations.has_active_session()",
  281. name="no_conversation",
  282. )