registration_manager.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. # -*- coding: utf-8 -*-
  2. """
  3. RegistrationManager für die Verwaltung von Satellite-Registrierungen.
  4. Satellites müssen vor der Verbindung registriert werden.
  5. Die Registrierung hat ein Timeout von 60 Sekunden.
  6. """
  7. import asyncio
  8. import json
  9. from datetime import datetime, timedelta
  10. from pathlib import Path
  11. from typing import TYPE_CHECKING
  12. from trixy_core.satellite.satellite import Satellite
  13. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  14. if TYPE_CHECKING:
  15. from trixy_core.application import IApplication
  16. class PendingRegistration:
  17. """Eine ausstehende Registrierung."""
  18. def __init__(self, mac_address: str, room: str, alias: str) -> None:
  19. self.mac_address = mac_address
  20. self.room = room
  21. self.alias = alias
  22. self.created_at = datetime.now()
  23. self.expires_at = datetime.now() + timedelta(seconds=60)
  24. def is_expired(self) -> bool:
  25. """Prüft, ob die Registrierung abgelaufen ist."""
  26. return datetime.now() > self.expires_at
  27. class RegistrationManager:
  28. """
  29. Verwaltet Satellite-Registrierungen.
  30. Registrierungsmodi:
  31. - Zeitfenster: 60-Sekunden Registrierungsfenster (Standard)
  32. - Permanent: --auto-regist Argument, dauerhaft offen
  33. - Datei-Trigger: regist.txt Datei, nächster Satellite wird registriert
  34. - Speicherung als JSON-Dateien nach MAC-Adresse
  35. - Automatisches Laden registrierter Satellites beim Start
  36. """
  37. REGISTRATION_TIMEOUT = 60 # Sekunden
  38. DEFAULT_DIRECTORY = "satellites"
  39. TRIGGER_FILE = "regist.txt"
  40. def __init__(
  41. self,
  42. application: "IApplication",
  43. directory: str | Path = DEFAULT_DIRECTORY
  44. ) -> None:
  45. """
  46. Initialisiert den RegistrationManager.
  47. Args:
  48. application: Referenz zur Hauptanwendung
  49. directory: Verzeichnis für Registrierungsdateien
  50. """
  51. self._application = application
  52. self._directory = Path(directory)
  53. self._pending: dict[str, PendingRegistration] = {}
  54. self._registration_mode = False
  55. self._permanent_mode = False
  56. self._file_trigger_enabled = False
  57. self._file_trigger_active = False # Durch Datei-Trigger aktiviert
  58. self._mode_task: asyncio.Task | None = None
  59. @property
  60. def registration_mode(self) -> bool:
  61. """Ist der Registrierungsmodus aktiv?"""
  62. return self._registration_mode or self._permanent_mode
  63. @property
  64. def is_permanent_mode(self) -> bool:
  65. """Ist der permanente Registrierungsmodus aktiv?"""
  66. return self._permanent_mode
  67. @property
  68. def is_file_trigger_enabled(self) -> bool:
  69. """Ist der Datei-Trigger aktiviert?"""
  70. return self._file_trigger_enabled
  71. @property
  72. def pending_count(self) -> int:
  73. """Anzahl ausstehender Registrierungen."""
  74. return len(self._pending)
  75. def enable_registration_mode(self, timeout_seconds: int = 60) -> None:
  76. """
  77. Aktiviert den Registrierungsmodus.
  78. Args:
  79. timeout_seconds: Zeit bis zur automatischen Deaktivierung
  80. """
  81. self._registration_mode = True
  82. pinfo(f"Registrierungsmodus aktiviert ({timeout_seconds}s)")
  83. # Bestehenden Task abbrechen
  84. if self._mode_task and not self._mode_task.done():
  85. self._mode_task.cancel()
  86. # Neuen Timeout-Task starten (nur wenn Event-Loop läuft)
  87. async def timeout() -> None:
  88. await asyncio.sleep(timeout_seconds)
  89. self.disable_registration_mode()
  90. try:
  91. loop = asyncio.get_running_loop()
  92. self._mode_task = loop.create_task(timeout())
  93. except RuntimeError:
  94. # Kein laufender Event-Loop (z.B. in Tests)
  95. self._mode_task = None
  96. def disable_registration_mode(self) -> None:
  97. """Deaktiviert den Registrierungsmodus."""
  98. self._registration_mode = False
  99. self._pending.clear()
  100. if self._mode_task and not self._mode_task.done():
  101. self._mode_task.cancel()
  102. self._mode_task = None
  103. pinfo("Registrierungsmodus deaktiviert")
  104. def enable_permanent_registration_mode(self) -> None:
  105. """
  106. Aktiviert den permanenten Registrierungsmodus.
  107. Server registriert automatisch alle neuen Satellites
  108. ohne Timeout. Wird über --auto-regist Argument aktiviert.
  109. """
  110. self._permanent_mode = True
  111. self._registration_mode = True
  112. pinfo("Permanenter Auto-Registrierungsmodus aktiviert")
  113. def disable_permanent_registration_mode(self) -> None:
  114. """Deaktiviert den permanenten Registrierungsmodus."""
  115. self._permanent_mode = False
  116. self._registration_mode = False
  117. pinfo("Permanenter Auto-Registrierungsmodus deaktiviert")
  118. def enable_file_trigger(self) -> None:
  119. """
  120. Aktiviert den Datei-Trigger für Registrierungen.
  121. Wenn eine regist.txt Datei im aktuellen Verzeichnis existiert,
  122. wird der nächste unregistrierte Satellite automatisch registriert
  123. und die Datei gelöscht.
  124. """
  125. self._file_trigger_enabled = True
  126. pdebug(f"Datei-Trigger aktiviert ({self.TRIGGER_FILE})")
  127. def disable_file_trigger(self) -> None:
  128. """Deaktiviert den Datei-Trigger."""
  129. self._file_trigger_enabled = False
  130. pdebug("Datei-Trigger deaktiviert")
  131. def check_file_trigger(self) -> bool:
  132. """
  133. Prüft ob der Datei-Trigger aktiv ist.
  134. Wenn die Datei existiert, wird sie gelöscht und ein
  135. einmaliger Registrierungsmodus für den nächsten Satellite aktiviert.
  136. Returns:
  137. True wenn regist.txt existiert (und gelöscht wurde)
  138. """
  139. if not self._file_trigger_enabled:
  140. return False
  141. trigger_path = Path(self.TRIGGER_FILE)
  142. if trigger_path.exists():
  143. try:
  144. trigger_path.unlink()
  145. pinfo(f"Registrierungs-Trigger erkannt ({self.TRIGGER_FILE})")
  146. # Aktiviere einmaligen Registrierungsmodus
  147. self._registration_mode = True
  148. self._file_trigger_active = True
  149. return True
  150. except Exception as e:
  151. perror(f"Fehler beim Löschen der Trigger-Datei: {e}")
  152. return False
  153. return False
  154. def should_auto_register(self) -> bool:
  155. """
  156. Prüft ob automatische Registrierung erlaubt ist.
  157. Berücksichtigt:
  158. - Permanenter Modus (--auto-regist)
  159. - Zeitfenster-Modus (enable_registration_mode)
  160. - Datei-Trigger (regist.txt)
  161. Returns:
  162. True wenn Registrierung erlaubt
  163. """
  164. # Permanenter Modus hat höchste Priorität
  165. if self._permanent_mode:
  166. return True
  167. # Zeitfenster-Modus (inkl. durch Datei-Trigger aktiviert)
  168. if self._registration_mode:
  169. return True
  170. # Datei-Trigger prüfen (aktiviert _registration_mode wenn gefunden)
  171. if self.check_file_trigger():
  172. return True
  173. return False
  174. def begin_registration(
  175. self,
  176. mac_address: str,
  177. room: str,
  178. alias: str
  179. ) -> bool:
  180. """
  181. Startet eine neue Registrierung.
  182. Args:
  183. mac_address: MAC-Adresse des Satellites
  184. room: Raum-Kennung
  185. alias: Anzeigename
  186. Returns:
  187. True wenn erfolgreich gestartet
  188. """
  189. if not self.should_auto_register():
  190. pwarn("Registrierungsversuch ohne aktiven Registrierungsmodus")
  191. return False
  192. # Bestehende abgelaufene Registrierungen entfernen
  193. self._cleanup_expired()
  194. # Neue Registrierung erstellen
  195. self._pending[mac_address] = PendingRegistration(mac_address, room, alias)
  196. pdebug(f"Registrierung gestartet: {alias} ({mac_address})")
  197. return True
  198. def complete_registration(self, mac_address: str) -> Satellite | None:
  199. """
  200. Schließt eine Registrierung ab.
  201. Args:
  202. mac_address: MAC-Adresse des Satellites
  203. Returns:
  204. Der registrierte Satellite oder None
  205. """
  206. pending = self._pending.pop(mac_address, None)
  207. if pending is None:
  208. perror(f"Keine ausstehende Registrierung für: {mac_address}")
  209. return None
  210. if pending.is_expired():
  211. perror(f"Registrierung abgelaufen: {mac_address}")
  212. return None
  213. # Satellite erstellen
  214. satellite = Satellite(
  215. room_id=pending.room,
  216. mac_address=pending.mac_address,
  217. alias=pending.alias
  218. )
  219. # In Datei speichern
  220. self._save_registration(satellite)
  221. # Wenn durch Datei-Trigger aktiviert, Modus zurücksetzen
  222. if self._file_trigger_active:
  223. self._registration_mode = False
  224. self._file_trigger_active = False
  225. pdebug("Datei-Trigger-Modus zurückgesetzt")
  226. pinfo(f"Satellite registriert: {satellite.alias} ({satellite.mac_address})")
  227. return satellite
  228. def cancel_registration(self, mac_address: str) -> bool:
  229. """
  230. Bricht eine ausstehende Registrierung ab.
  231. Args:
  232. mac_address: MAC-Adresse
  233. Returns:
  234. True wenn abgebrochen
  235. """
  236. if mac_address in self._pending:
  237. del self._pending[mac_address]
  238. pdebug(f"Registrierung abgebrochen: {mac_address}")
  239. return True
  240. return False
  241. def is_registered(self, mac_address: str) -> bool:
  242. """
  243. Prüft, ob ein Satellite registriert ist.
  244. Args:
  245. mac_address: MAC-Adresse
  246. Returns:
  247. True wenn registriert
  248. """
  249. file_path = self._get_file_path(mac_address)
  250. return file_path.exists()
  251. def load_registered(self, mac_address: str) -> Satellite | None:
  252. """
  253. Lädt einen registrierten Satellite.
  254. Args:
  255. mac_address: MAC-Adresse
  256. Returns:
  257. Satellite oder None
  258. """
  259. file_path = self._get_file_path(mac_address)
  260. if not file_path.exists():
  261. return None
  262. try:
  263. with open(file_path, "r", encoding="utf-8") as f:
  264. data = json.load(f)
  265. satellite = Satellite(
  266. satellite_id=data.get("id"),
  267. room_id=data.get("room_id", ""),
  268. mac_address=data.get("mac_address", mac_address),
  269. alias=data.get("alias", "")
  270. )
  271. pdebug(f"Satellite geladen: {satellite.alias}")
  272. return satellite
  273. except Exception as e:
  274. perror(f"Fehler beim Laden von {mac_address}: {e}")
  275. return None
  276. def load_all(self) -> list[Satellite]:
  277. """
  278. Lädt alle registrierten Satellites.
  279. Returns:
  280. Liste aller registrierten Satellites
  281. """
  282. satellites: list[Satellite] = []
  283. if not self._directory.exists():
  284. return satellites
  285. for file_path in self._directory.glob("*.json"):
  286. mac_address = file_path.stem
  287. satellite = self.load_registered(mac_address)
  288. if satellite:
  289. satellites.append(satellite)
  290. pinfo(f"{len(satellites)} registrierte Satellites geladen")
  291. return satellites
  292. def delete_registration(self, mac_address: str) -> bool:
  293. """
  294. Löscht eine Registrierung.
  295. Args:
  296. mac_address: MAC-Adresse
  297. Returns:
  298. True wenn gelöscht
  299. """
  300. file_path = self._get_file_path(mac_address)
  301. if file_path.exists():
  302. file_path.unlink()
  303. pinfo(f"Registrierung gelöscht: {mac_address}")
  304. return True
  305. return False
  306. def _save_registration(self, satellite: Satellite) -> None:
  307. """Speichert eine Registrierung in eine Datei."""
  308. self._directory.mkdir(parents=True, exist_ok=True)
  309. file_path = self._get_file_path(satellite.mac_address)
  310. data = {
  311. "id": satellite.id,
  312. "room_id": satellite.room_id,
  313. "mac_address": satellite.mac_address,
  314. "alias": satellite.alias,
  315. "registered_at": datetime.now().isoformat()
  316. }
  317. with open(file_path, "w", encoding="utf-8") as f:
  318. json.dump(data, f, indent=2, ensure_ascii=False)
  319. pdebug(f"Registrierung gespeichert: {file_path}")
  320. def _get_file_path(self, mac_address: str) -> Path:
  321. """Gibt den Dateipfad für eine MAC-Adresse zurück."""
  322. # MAC-Adresse für Dateinamen bereinigen
  323. safe_mac = mac_address.replace(":", "-").replace(".", "-")
  324. return self._directory / f"{safe_mac}.json"
  325. def _cleanup_expired(self) -> None:
  326. """Entfernt abgelaufene Registrierungen."""
  327. expired = [mac for mac, reg in self._pending.items() if reg.is_expired()]
  328. for mac in expired:
  329. del self._pending[mac]
  330. pdebug(f"Abgelaufene Registrierung entfernt: {mac}")