""" Classroom API - WebSocket Connection Manager Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates. """ import asyncio import json import logging from typing import Dict, List, Optional from datetime import datetime from fastapi import WebSocket logger = logging.getLogger(__name__) class ConnectionManager: """ Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates. Features: - Session-basierte Verbindungen (jede Session hat eigene Clients) - Automatisches Cleanup bei Disconnect - Broadcast an alle Clients einer Session - Multi-Device Support """ def __init__(self): # session_id -> Set[WebSocket] self._connections: Dict[str, set] = {} # WebSocket -> session_id (reverse lookup) self._websocket_sessions: Dict[WebSocket, str] = {} self._lock = asyncio.Lock() async def connect(self, websocket: WebSocket, session_id: str): """Verbindet einen Client mit einer Session.""" await websocket.accept() async with self._lock: if session_id not in self._connections: self._connections[session_id] = set() self._connections[session_id].add(websocket) self._websocket_sessions[websocket] = session_id logger.info(f"WebSocket connected to session {session_id}, total clients: {len(self._connections[session_id])}") async def disconnect(self, websocket: WebSocket): """Trennt einen Client.""" async with self._lock: session_id = self._websocket_sessions.pop(websocket, None) if session_id and session_id in self._connections: self._connections[session_id].discard(websocket) if not self._connections[session_id]: del self._connections[session_id] logger.info(f"WebSocket disconnected from session {session_id}") async def broadcast_to_session(self, session_id: str, message: dict): """Sendet eine Nachricht an alle Clients einer Session.""" async with self._lock: connections = self._connections.get(session_id, set()).copy() if not connections: return message_json = json.dumps(message) dead_connections = [] for websocket in connections: try: await websocket.send_text(message_json) except Exception as e: logger.warning(f"Failed to send to websocket: {e}") dead_connections.append(websocket) # Cleanup dead connections for ws in dead_connections: await self.disconnect(ws) async def broadcast_timer_update(self, session_id: str, timer_data: dict): """Sendet Timer-Update an alle Clients einer Session.""" await self.broadcast_to_session(session_id, { "type": "timer_update", "data": timer_data }) async def broadcast_phase_change(self, session_id: str, phase_data: dict): """Sendet Phasenwechsel-Event an alle Clients.""" await self.broadcast_to_session(session_id, { "type": "phase_change", "data": phase_data }) async def broadcast_session_ended(self, session_id: str): """Sendet Session-Ende-Event an alle Clients.""" await self.broadcast_to_session(session_id, { "type": "session_ended", "data": {"session_id": session_id} }) def get_client_count(self, session_id: str) -> int: """Gibt die Anzahl der verbundenen Clients fuer eine Session zurueck.""" return len(self._connections.get(session_id, set())) def get_active_sessions(self) -> List[str]: """Gibt alle Sessions mit aktiven WebSocket-Verbindungen zurueck.""" return list(self._connections.keys()) # Global connection manager instance ws_manager = ConnectionManager() # Background task handle _timer_broadcast_task: Optional[asyncio.Task] = None def build_timer_status(session) -> dict: """ Baut Timer-Status als dict fuer WebSocket-Broadcast. Returns dict mit allen Timer-Feldern die der Client benoetigt. """ from classroom_engine import PhaseTimer timer = PhaseTimer() status = timer.get_phase_status(session) # Zusaetzliche Felder fuer WebSocket status["session_id"] = session.session_id status["current_phase"] = session.current_phase.value status["is_paused"] = session.is_paused status["timestamp"] = datetime.utcnow().isoformat() return status async def timer_broadcast_loop(sessions_dict: Dict): """ Hintergrund-Task der Timer-Updates alle 1 Sekunde an verbundene Clients sendet. Features: - Sub-Sekunden Genauigkeit (jede Sekunde) - Nur aktive Sessions werden aktualisiert - Automatisches Cleanup bei Fehlern """ logger.info("Timer broadcast loop started") while True: try: await asyncio.sleep(1) active_ws_sessions = ws_manager.get_active_sessions() if not active_ws_sessions: continue for session_id in active_ws_sessions: session = sessions_dict.get(session_id) if not session or session.is_ended: continue # Timer-Status berechnen timer_status = build_timer_status(session) # An alle Clients senden await ws_manager.broadcast_timer_update(session_id, timer_status) except asyncio.CancelledError: logger.info("Timer broadcast loop cancelled") break except Exception as e: logger.error(f"Error in timer broadcast loop: {e}") await asyncio.sleep(5) # Kurze Pause bei Fehler def start_timer_broadcast(sessions_dict: Dict): """Startet den Timer-Broadcast-Task wenn noch nicht laufend.""" global _timer_broadcast_task if _timer_broadcast_task is None or _timer_broadcast_task.done(): _timer_broadcast_task = asyncio.create_task(timer_broadcast_loop(sessions_dict)) logger.info("Timer broadcast task created") def stop_timer_broadcast(): """Stoppt den Timer-Broadcast-Task.""" global _timer_broadcast_task if _timer_broadcast_task and not _timer_broadcast_task.done(): _timer_broadcast_task.cancel() logger.info("Timer broadcast task cancelled") def is_timer_broadcast_running() -> bool: """Prueft ob der Timer-Broadcast-Task laeuft.""" return _timer_broadcast_task is not None and not _timer_broadcast_task.done() # Broadcast bei Phasenwechsel und Session-Ende async def notify_phase_change(session_id: str, new_phase: str, phase_info: dict): """Benachrichtigt alle verbundenen Clients ueber Phasenwechsel.""" await ws_manager.broadcast_phase_change(session_id, { "new_phase": new_phase, "phase_info": phase_info, "timestamp": datetime.utcnow().isoformat() }) async def notify_session_ended(session_id: str): """Benachrichtigt alle verbundenen Clients ueber Session-Ende.""" await ws_manager.broadcast_session_ended(session_id)