interval.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. # -*- coding: utf-8 -*-
  2. """
  3. Interval Trigger - Zeitbasierter Trigger mit festen Intervallen.
  4. """
  5. from datetime import datetime, timedelta
  6. from typing import Any
  7. from trixy_core.scheduler.form_fields import FieldType, FormField, FormValidationError
  8. from trixy_core.scheduler.trigger.base import Trigger, TriggerContext, TriggerState
  9. class IntervalTrigger(Trigger):
  10. """
  11. Trigger mit festem Zeitintervall.
  12. Beispiele:
  13. - IntervalTrigger(seconds=30) - Alle 30 Sekunden
  14. - IntervalTrigger(minutes=5) - Alle 5 Minuten
  15. - IntervalTrigger(hours=1) - Jede Stunde
  16. - IntervalTrigger(days=1, hours=12) - Alle 1,5 Tage
  17. """
  18. def __init__(
  19. self,
  20. weeks: int = 0,
  21. days: int = 0,
  22. hours: int = 0,
  23. minutes: int = 0,
  24. seconds: int = 0,
  25. start_time: datetime | None = None,
  26. end_time: datetime | None = None,
  27. jitter_seconds: float = 0,
  28. name: str | None = None,
  29. ):
  30. """
  31. Initialisiert den Interval-Trigger.
  32. Args:
  33. weeks: Wochen
  34. days: Tage
  35. hours: Stunden
  36. minutes: Minuten
  37. seconds: Sekunden
  38. start_time: Frühester Startzeitpunkt
  39. end_time: Spätester Endzeitpunkt
  40. jitter_seconds: Zufällige Abweichung (optional)
  41. name: Trigger-Name
  42. """
  43. super().__init__(name)
  44. self._weeks = weeks
  45. self._days = days
  46. self._hours = hours
  47. self._minutes = minutes
  48. self._seconds = seconds
  49. self._start_time = start_time
  50. self._end_time = end_time
  51. self._jitter_seconds = jitter_seconds
  52. # Interval berechnen
  53. self._interval = timedelta(
  54. weeks=weeks,
  55. days=days,
  56. hours=hours,
  57. minutes=minutes,
  58. seconds=seconds,
  59. )
  60. if self._interval.total_seconds() <= 0:
  61. raise ValueError("Interval muss größer als 0 sein")
  62. @property
  63. def interval(self) -> timedelta:
  64. """Intervall als timedelta."""
  65. return self._interval
  66. @property
  67. def interval_seconds(self) -> float:
  68. """Intervall in Sekunden."""
  69. return self._interval.total_seconds()
  70. @property
  71. def start_time(self) -> datetime | None:
  72. """Startzeitpunkt."""
  73. return self._start_time
  74. @property
  75. def end_time(self) -> datetime | None:
  76. """Endzeitpunkt."""
  77. return self._end_time
  78. def should_fire(self, context: TriggerContext) -> bool:
  79. """Prüft ob Trigger auslösen soll."""
  80. if not self.is_active:
  81. return False
  82. now = context.current_time
  83. # Zeitfenster prüfen
  84. if self._start_time and now < self._start_time:
  85. return False
  86. if self._end_time and now > self._end_time:
  87. self._state = TriggerState.FINISHED
  88. return False
  89. # Erstes Mal
  90. if not context.last_fire_time:
  91. return True
  92. # Interval prüfen
  93. elapsed = now - context.last_fire_time
  94. return elapsed >= self._interval
  95. def get_next_fire_time(
  96. self,
  97. last_fire_time: datetime | None = None,
  98. ) -> datetime | None:
  99. """Berechnet nächsten Auslösezeitpunkt."""
  100. if not self.is_active:
  101. return None
  102. now = datetime.now()
  103. # Basis für Berechnung
  104. if last_fire_time:
  105. next_time = last_fire_time + self._interval
  106. elif self._start_time and self._start_time > now:
  107. next_time = self._start_time
  108. else:
  109. next_time = now
  110. # Wenn nächster Zeitpunkt in Vergangenheit, auf jetzt setzen
  111. if next_time < now:
  112. # Berechne wie viele Intervalle vergangen sind
  113. elapsed = now - next_time
  114. intervals_passed = int(elapsed / self._interval) + 1
  115. next_time = next_time + (self._interval * intervals_passed)
  116. # Jitter hinzufügen
  117. if self._jitter_seconds > 0:
  118. import random
  119. jitter = random.uniform(-self._jitter_seconds, self._jitter_seconds)
  120. next_time = next_time + timedelta(seconds=jitter)
  121. # Ende prüfen
  122. if self._end_time and next_time > self._end_time:
  123. return None
  124. return next_time
  125. def to_dict(self) -> dict:
  126. """Konvertiert zu Dictionary."""
  127. return {
  128. **self._base_dict(),
  129. "weeks": self._weeks,
  130. "days": self._days,
  131. "hours": self._hours,
  132. "minutes": self._minutes,
  133. "seconds": self._seconds,
  134. "start_time": (
  135. self._start_time.isoformat() if self._start_time else None
  136. ),
  137. "end_time": (
  138. self._end_time.isoformat() if self._end_time else None
  139. ),
  140. "jitter_seconds": self._jitter_seconds,
  141. }
  142. @classmethod
  143. def from_dict(cls, data: dict) -> "IntervalTrigger":
  144. """Erstellt Trigger aus Dictionary."""
  145. start_time = None
  146. end_time = None
  147. if data.get("start_time"):
  148. start_time = datetime.fromisoformat(data["start_time"])
  149. if data.get("end_time"):
  150. end_time = datetime.fromisoformat(data["end_time"])
  151. return cls(
  152. weeks=data.get("weeks", 0),
  153. days=data.get("days", 0),
  154. hours=data.get("hours", 0),
  155. minutes=data.get("minutes", 0),
  156. seconds=data.get("seconds", 0),
  157. start_time=start_time,
  158. end_time=end_time,
  159. jitter_seconds=data.get("jitter_seconds", 0),
  160. name=data.get("name"),
  161. )
  162. @classmethod
  163. def display_name(cls) -> str:
  164. """Anzeigename fuer die TUI."""
  165. return "Intervall"
  166. @classmethod
  167. def form_fields(cls) -> list[FormField]:
  168. """Gibt die Formular-Felder fuer die TUI zurueck."""
  169. return [
  170. FormField(
  171. name="weeks", label="Wochen", field_type=FieldType.NUMBER,
  172. default=0, min_value=0, group="Intervall",
  173. ),
  174. FormField(
  175. name="days", label="Tage", field_type=FieldType.NUMBER,
  176. default=0, min_value=0, group="Intervall",
  177. ),
  178. FormField(
  179. name="hours", label="Stunden", field_type=FieldType.NUMBER,
  180. default=0, min_value=0, group="Intervall",
  181. ),
  182. FormField(
  183. name="minutes", label="Minuten", field_type=FieldType.NUMBER,
  184. default=0, min_value=0, group="Intervall",
  185. ),
  186. FormField(
  187. name="seconds", label="Sekunden", field_type=FieldType.NUMBER,
  188. default=0, min_value=0, group="Intervall",
  189. ),
  190. FormField(
  191. name="start_time", label="Startzeit", field_type=FieldType.DATETIME,
  192. placeholder="2024-01-01T00:00:00", group="Zeitfenster",
  193. ),
  194. FormField(
  195. name="end_time", label="Endzeit", field_type=FieldType.DATETIME,
  196. placeholder="2024-12-31T23:59:59", group="Zeitfenster",
  197. ),
  198. FormField(
  199. name="jitter_seconds", label="Jitter (Sek.)", field_type=FieldType.NUMBER,
  200. default=0, min_value=0, is_float=True,
  201. ),
  202. ]
  203. @classmethod
  204. def validate_form(cls, data: dict) -> list[FormValidationError]:
  205. """Validiert Formular-Daten. Gibt Liste von Fehlern zurueck."""
  206. errors: list[FormValidationError] = []
  207. total = sum(data.get(k, 0) for k in ("weeks", "days", "hours", "minutes", "seconds"))
  208. if total <= 0:
  209. errors.append(FormValidationError("seconds", "Intervall muss groesser als 0 sein"))
  210. return errors
  211. def __repr__(self) -> str:
  212. return f"IntervalTrigger(interval={self._interval})"