""" Tapo Status Manager - Verwaltung der 3 Steckdosen-Status Dieser Manager stellt sicher, dass: 1. Alle 6 Drucker/Steckdosen immer angezeigt werden 2. Die 3 Status korrekt verwaltet werden: an, aus, nicht erreichbar 3. Der Status persistent gespeichert wird 4. Die automatische Steuerung basierend auf Jobs funktioniert """ from typing import Dict, Tuple, Optional, List from datetime import datetime, timedelta import asyncio from concurrent.futures import ThreadPoolExecutor import threading from models import Printer, Job, PlugStatusLog, get_db_session from utils.logging_config import get_logger logger = get_logger("tapo_status_manager") class TapoStatusManager: """ Zentraler Manager für Tapo-Steckdosen-Status """ # Die 3 möglichen Status-Zustände STATUS_ON = "on" STATUS_OFF = "off" STATUS_UNREACHABLE = "unreachable" # Status-Mapping für UI STATUS_DISPLAY = { STATUS_ON: {"text": "An", "color": "green", "icon": "power"}, STATUS_OFF: {"text": "Aus", "color": "gray", "icon": "power-off"}, STATUS_UNREACHABLE: {"text": "Nicht erreichbar", "color": "red", "icon": "exclamation-triangle"} } def __init__(self): """Initialisiert den Status-Manager""" self._status_cache = {} self._cache_lock = threading.RLock() self._last_check = {} self.check_interval = 30 # Sekunden zwischen Status-Checks # Session-spezifischer Status-Cache für Benutzer-Sessions self._session_cache = {} self._session_cache_lock = threading.RLock() self._session_cache_ttl = 300 # 5 Minuten für Session-Cache # Thread-Pool für asynchrone Operationen self._executor = ThreadPoolExecutor(max_workers=6) logger.info("TapoStatusManager mit Session-Caching initialisiert") def get_printer_status(self, printer_id: int, force_refresh: bool = False) -> Dict[str, any]: """ Gibt den aktuellen Status eines Druckers zurück Args: printer_id: ID des Druckers force_refresh: True = Cache umgehen und echten Netzwerk-Test durchführen Returns: Dict mit Status-Informationen """ if not force_refresh: with self._cache_lock: # Aus Cache holen wenn vorhanden und aktuell if printer_id in self._status_cache: cache_data = self._status_cache[printer_id] if self._is_cache_valid(printer_id): return cache_data # Neuen Status abrufen (mit Cache-Invalidierung bei force_refresh) if force_refresh: self.invalidate_cache(printer_id) return self._fetch_printer_status(printer_id) def get_all_printer_status(self, force_refresh: bool = False) -> List[Dict[str, any]]: """ Gibt den Status aller Drucker zurück Args: force_refresh: True = Cache für alle Drucker umgehen Returns: Liste mit Status-Informationen aller Drucker """ try: db_session = get_db_session() printers = db_session.query(Printer).all() status_list = [] # Status für jeden Drucker abrufen for printer in printers: status = self.get_printer_status(printer.id, force_refresh=force_refresh) status_list.append(status) db_session.close() return status_list except Exception as e: logger.error(f"Fehler beim Abrufen aller Drucker-Status: {str(e)}") return [] def _fetch_printer_status(self, printer_id: int) -> Dict[str, any]: """ Holt den aktuellen Status eines Druckers Args: printer_id: ID des Druckers Returns: Dict mit Status-Informationen """ try: db_session = get_db_session() printer = db_session.query(Printer).filter(Printer.id == printer_id).first() if not printer: logger.warning(f"Drucker {printer_id} nicht gefunden") return self._create_error_status(printer_id, "Drucker nicht gefunden") # Basis-Status erstellen status_info = { "id": printer.id, "name": printer.name, "model": printer.model, "location": printer.location, "ip_address": printer.ip_address, "has_plug": bool(printer.plug_ip), "plug_ip": printer.plug_ip, "active": printer.active, "last_checked": datetime.now() } # Wenn keine Steckdose konfiguriert if not printer.plug_ip: status_info.update({ "plug_status": "no_plug", "plug_reachable": False, "power_status": None, "can_control": False }) else: # Tapo-Status abrufen plug_status = self._check_tapo_status(printer) status_info.update(plug_status) # Aktuelle Jobs prüfen active_job = self._get_active_job(printer_id, db_session) if active_job: status_info["current_job"] = { "id": active_job.id, "name": active_job.name, "user": active_job.user.name, "start_at": active_job.start_at.isoformat(), "end_at": active_job.end_at.isoformat(), "status": active_job.status } else: status_info["current_job"] = None # Nächster geplanter Job next_job = self._get_next_job(printer_id, db_session) if next_job: status_info["next_job"] = { "id": next_job.id, "name": next_job.name, "user": next_job.user.name, "start_at": next_job.start_at.isoformat(), "starts_in_minutes": int((next_job.start_at - datetime.now()).total_seconds() / 60) } else: status_info["next_job"] = None # Status in Cache speichern with self._cache_lock: self._status_cache[printer_id] = status_info self._last_check[printer_id] = datetime.now() # Status in Datenbank loggen self._log_status(printer, status_info.get("plug_status", "unknown")) db_session.close() return status_info except Exception as e: logger.error(f"Fehler beim Abrufen des Status für Drucker {printer_id}: {str(e)}") return self._create_error_status(printer_id, str(e)) def _check_tapo_status(self, printer: Printer) -> Dict[str, any]: """ Prüft den Tapo-Steckdosen-Status mit erweiterten Fallback-Mechanismen Args: printer: Printer-Objekt Returns: Dict mit Tapo-Status """ try: # Tapo-Controller importieren from utils.hardware_integration import tapo_controller if not tapo_controller: logger.warning(f"Tapo-Controller nicht verfügbar für {printer.name}") return { "plug_status": self.STATUS_UNREACHABLE, "plug_reachable": False, "power_status": None, "can_control": False, "error": "Tapo-Controller nicht verfügbar" } # Status abrufen mit Debug-Informationen logger.debug(f"Prüfe Tapo-Status für {printer.name} ({printer.plug_ip})") reachable, plug_status = tapo_controller.check_outlet_status( printer.plug_ip, printer_id=printer.id, debug=False # Weniger Debug-Output für bessere Performance ) if reachable: # Erfolgreiche Verbindung logger.debug(f"✅ Tapo-Steckdose {printer.plug_ip} erreichbar - Status: {plug_status}") # Status normalisieren if plug_status in ["on", "true", "1", True]: normalized_status = self.STATUS_ON power_status = "on" elif plug_status in ["off", "false", "0", False]: normalized_status = self.STATUS_OFF power_status = "off" else: # Unbekannter Status, aber erreichbar normalized_status = self.STATUS_UNREACHABLE power_status = "unknown" logger.warning(f"Unbekannter Tapo-Status '{plug_status}' für {printer.name}") return { "plug_status": normalized_status, "plug_reachable": True, "power_status": power_status, "can_control": True, "last_check": datetime.now().isoformat() } else: # Steckdose nicht erreichbar logger.warning(f"⚠️ Tapo-Steckdose {printer.plug_ip} nicht erreichbar für {printer.name}") return { "plug_status": self.STATUS_UNREACHABLE, "plug_reachable": False, "power_status": None, "can_control": False, "error": "Steckdose nicht erreichbar", "last_check": datetime.now().isoformat() } except ImportError as e: logger.error(f"Import-Fehler beim Tapo-Controller für {printer.name}: {str(e)}") return { "plug_status": self.STATUS_UNREACHABLE, "plug_reachable": False, "power_status": None, "can_control": False, "error": f"Import-Fehler: {str(e)}", "fallback_used": True } except Exception as e: logger.error(f"Unerwarteter Fehler beim Prüfen des Tapo-Status für {printer.name}: {str(e)}") return { "plug_status": self.STATUS_UNREACHABLE, "plug_reachable": False, "power_status": None, "can_control": False, "error": str(e), "last_check": datetime.now().isoformat() } def control_plug(self, printer_id: int, action: str) -> Tuple[bool, str]: """ Steuert eine Tapo-Steckdose Args: printer_id: ID des Druckers action: "on" oder "off" Returns: Tuple (Erfolg, Nachricht) """ try: db_session = get_db_session() printer = db_session.query(Printer).filter(Printer.id == printer_id).first() if not printer: return False, "Drucker nicht gefunden" if not printer.plug_ip: return False, "Keine Steckdose konfiguriert" # Tapo-Controller verwenden from utils.hardware_integration import tapo_controller if not tapo_controller: return False, "Tapo-Controller nicht verfügbar" # Aktion ausführen success = False if action == "on": success = tapo_controller.turn_on_outlet(printer.plug_ip, printer_id) elif action == "off": success = tapo_controller.turn_off_outlet(printer.plug_ip, printer_id) else: return False, f"Ungültige Aktion: {action}" if success: # Cache invalidieren with self._cache_lock: if printer_id in self._status_cache: del self._status_cache[printer_id] # Status loggen self._log_status(printer, action, source="manual") db_session.close() return True, f"Steckdose erfolgreich {action}" else: db_session.close() return False, "Steckdose konnte nicht gesteuert werden" except Exception as e: logger.error(f"Fehler beim Steuern der Steckdose für Drucker {printer_id}: {str(e)}") return False, str(e) def check_and_control_for_jobs(self): """ Prüft alle Jobs und steuert Steckdosen entsprechend Diese Methode sollte regelmäßig vom Scheduler aufgerufen werden """ try: db_session = get_db_session() now = datetime.now() # Jobs die starten sollten jobs_to_start = db_session.query(Job).filter( Job.status == "scheduled", Job.start_at <= now ).all() for job in jobs_to_start: logger.info(f"Starte Job {job.id} für Drucker {job.printer_id}") success, msg = self.control_plug(job.printer_id, "on") if success: job.status = "running" logger.info(f"Steckdose für Job {job.id} eingeschaltet") else: logger.error(f"Fehler beim Einschalten für Job {job.id}: {msg}") # Jobs die enden sollten jobs_to_end = db_session.query(Job).filter( Job.status == "running", Job.end_at <= now ).all() for job in jobs_to_end: logger.info(f"Beende Job {job.id} für Drucker {job.printer_id}") success, msg = self.control_plug(job.printer_id, "off") if success: job.status = "finished" job.actual_end_time = now logger.info(f"Steckdose für Job {job.id} ausgeschaltet") else: logger.error(f"Fehler beim Ausschalten für Job {job.id}: {msg}") db_session.commit() db_session.close() except Exception as e: logger.error(f"Fehler bei der automatischen Job-Steuerung: {str(e)}") def _get_active_job(self, printer_id: int, db_session) -> Optional[Job]: """Gibt den aktuell aktiven Job für einen Drucker zurück""" return db_session.query(Job).filter( Job.printer_id == printer_id, Job.status == "running" ).first() def _get_next_job(self, printer_id: int, db_session) -> Optional[Job]: """Gibt den nächsten geplanten Job für einen Drucker zurück""" return db_session.query(Job).filter( Job.printer_id == printer_id, Job.status == "scheduled", Job.start_at > datetime.now() ).order_by(Job.start_at).first() def _is_cache_valid(self, printer_id: int) -> bool: """Prüft ob der Cache noch gültig ist""" if printer_id not in self._last_check: return False age = (datetime.now() - self._last_check[printer_id]).total_seconds() return age < self.check_interval def _create_error_status(self, printer_id: int, error: str) -> Dict[str, any]: """Erstellt einen Fehler-Status""" return { "id": printer_id, "name": f"Drucker {printer_id}", "plug_status": self.STATUS_UNREACHABLE, "plug_reachable": False, "error": error, "last_checked": datetime.now() } def _log_status(self, printer: Printer, status: str, source: str = "system"): """Loggt einen Status in die Datenbank""" try: PlugStatusLog.log_status_change( printer_id=printer.id, status=status, source=source, ip_address=printer.plug_ip ) except Exception as e: logger.error(f"Fehler beim Loggen des Status: {str(e)}") def get_status_for_calendar(self, start_date: datetime, end_date: datetime) -> List[Dict]: """ Gibt Status-Informationen für die Kalender-Ansicht zurück Args: start_date: Start-Datum end_date: End-Datum Returns: Liste mit Status-Events für den Kalender """ try: db_session = get_db_session() # Jobs im Zeitraum abrufen jobs = db_session.query(Job).filter( Job.start_at <= end_date, Job.end_at >= start_date ).all() events = [] for job in jobs: # Drucker-Status für Job printer = job.printer status = self.get_printer_status(printer.id) event = { "id": f"job_{job.id}", "title": f"{printer.name}: {job.name}", "start": job.start_at.isoformat(), "end": job.end_at.isoformat(), "backgroundColor": self._get_status_color(job.status), "extendedProps": { "job_id": job.id, "printer_id": printer.id, "printer_name": printer.name, "printer_status": status.get("plug_status", "unknown"), "job_status": job.status, "user": job.user.name, "plug_reachable": status.get("plug_reachable", False) } } events.append(event) db_session.close() return events except Exception as e: logger.error(f"Fehler beim Abrufen der Kalender-Status: {str(e)}") return [] def get_session_status(self, session_id: str, printer_ids: List[int] = None) -> Dict[str, any]: """ Holt den gecachten Status für eine Session Args: session_id: Session-ID printer_ids: Optional - spezifische Drucker-IDs Returns: Dict mit Session-spezifischen Status-Daten """ try: with self._session_cache_lock: session_data = self._session_cache.get(session_id, {}) # Prüfe Cache-Gültigkeit cache_time = session_data.get('timestamp', datetime.min) if (datetime.now() - cache_time).total_seconds() > self._session_cache_ttl: # Cache abgelaufen self._session_cache.pop(session_id, None) return self._create_fresh_session_status(session_id, printer_ids) # Wenn spezifische Drucker angefragt, filtere diese if printer_ids: filtered_status = {} for printer_id in printer_ids: if str(printer_id) in session_data.get('printers', {}): filtered_status[str(printer_id)] = session_data['printers'][str(printer_id)] return { 'timestamp': session_data['timestamp'], 'session_id': session_id, 'printers': filtered_status, 'from_cache': True } return session_data except Exception as e: logger.error(f"Fehler beim Abrufen des Session-Status: {str(e)}") return self._create_fresh_session_status(session_id, printer_ids) def update_session_status(self, session_id: str, printer_id: int = None) -> bool: """ Aktualisiert den Session-Status-Cache Args: session_id: Session-ID printer_id: Optional - spezifischer Drucker Returns: bool: True wenn erfolgreich """ try: with self._session_cache_lock: if printer_id: # Einzelnen Drucker aktualisieren printer_status = self.get_printer_status(printer_id) if session_id not in self._session_cache: self._session_cache[session_id] = { 'timestamp': datetime.now(), 'session_id': session_id, 'printers': {} } self._session_cache[session_id]['printers'][str(printer_id)] = printer_status self._session_cache[session_id]['timestamp'] = datetime.now() else: # Alle Drucker aktualisieren self._session_cache[session_id] = self._create_fresh_session_status(session_id) logger.debug(f"Session-Status für {session_id} aktualisiert") return True except Exception as e: logger.error(f"Fehler beim Aktualisieren des Session-Status: {str(e)}") return False def clear_session_cache(self, session_id: str = None) -> bool: """ Löscht Session-Cache Args: session_id: Optional - spezifische Session, sonst alle Returns: bool: True wenn erfolgreich """ try: with self._session_cache_lock: if session_id: self._session_cache.pop(session_id, None) logger.debug(f"Session-Cache für {session_id} gelöscht") else: self._session_cache.clear() logger.debug("Kompletter Session-Cache gelöscht") return True except Exception as e: logger.error(f"Fehler beim Löschen des Session-Cache: {str(e)}") return False def _create_fresh_session_status(self, session_id: str, printer_ids: List[int] = None) -> Dict[str, any]: """ Erstellt frischen Session-Status Args: session_id: Session-ID printer_ids: Optional - spezifische Drucker-IDs Returns: Dict mit frischen Status-Daten """ try: db_session = get_db_session() # Alle oder spezifische Drucker laden if printer_ids: printers = db_session.query(Printer).filter(Printer.id.in_(printer_ids)).all() else: printers = db_session.query(Printer).all() session_data = { 'timestamp': datetime.now(), 'session_id': session_id, 'printers': {}, 'from_cache': False } # Status für jeden Drucker abrufen for printer in printers: printer_status = self.get_printer_status(printer.id) session_data['printers'][str(printer.id)] = printer_status # In Session-Cache speichern with self._session_cache_lock: self._session_cache[session_id] = session_data db_session.close() return session_data except Exception as e: logger.error(f"Fehler beim Erstellen frischen Session-Status: {str(e)}") return { 'timestamp': datetime.now(), 'session_id': session_id, 'printers': {}, 'error': str(e), 'from_cache': False } def get_session_cache_stats(self) -> Dict[str, any]: """ Gibt Session-Cache-Statistiken zurück Returns: Dict mit Cache-Statistiken """ try: with self._session_cache_lock: stats = { 'total_sessions': len(self._session_cache), 'cache_ttl_seconds': self._session_cache_ttl, 'cache_size_bytes': len(str(self._session_cache)), 'sessions': {} } for session_id, data in self._session_cache.items(): stats['sessions'][session_id] = { 'timestamp': data.get('timestamp', datetime.min).isoformat(), 'printer_count': len(data.get('printers', {})), 'age_seconds': (datetime.now() - data.get('timestamp', datetime.now())).total_seconds() } return stats except Exception as e: logger.error(f"Fehler beim Abrufen der Cache-Statistiken: {str(e)}") return {'error': str(e)} def cleanup_expired_session_cache(self) -> int: """ Bereinigt abgelaufene Session-Cache-Einträge Returns: int: Anzahl gelöschter Einträge """ try: expired_count = 0 current_time = datetime.now() with self._session_cache_lock: expired_sessions = [] for session_id, data in self._session_cache.items(): cache_time = data.get('timestamp', datetime.min) if (current_time - cache_time).total_seconds() > self._session_cache_ttl: expired_sessions.append(session_id) for session_id in expired_sessions: self._session_cache.pop(session_id, None) expired_count += 1 if expired_count > 0: logger.info(f"Session-Cache bereinigt: {expired_count} abgelaufene Einträge entfernt") return expired_count except Exception as e: logger.error(f"Fehler beim Bereinigen des Session-Cache: {str(e)}") return 0 def invalidate_cache(self, printer_id: int = None) -> bool: """ Invalidiert Cache für spezifischen Drucker oder alle Args: printer_id: Optional - spezifischer Drucker, None = alle Drucker Returns: bool: True wenn erfolgreich """ try: with self._cache_lock: if printer_id is not None: # Spezifischen Drucker-Cache löschen self._status_cache.pop(printer_id, None) self._last_check.pop(printer_id, None) logger.debug(f"Cache für Drucker {printer_id} invalidiert") else: # Alle Caches löschen self._status_cache.clear() self._last_check.clear() logger.info("Kompletter Status-Cache invalidiert") return True except Exception as e: logger.error(f"Fehler beim Invalidieren des Cache: {str(e)}") return False def invalidate_all_caches(self) -> bool: """ Invalidiert alle Cache-Systeme (Status + Session) Verwendet bei Netzwerkwechseln oder Force-Refresh Returns: bool: True wenn erfolgreich """ try: # Status-Cache invalidieren self.invalidate_cache() # Session-Cache invalidieren self.clear_session_cache() logger.info("Alle Caches invalidiert (Status + Session)") return True except Exception as e: logger.error(f"Fehler beim Invalidieren aller Caches: {str(e)}") return False def force_network_refresh(self) -> Dict[str, any]: """ Forciert komplette Netzwerk-Neuprüfung aller Drucker Invalidiert alle Caches und führt echte Netzwerk-Tests durch Returns: Dict mit Refresh-Ergebnissen """ try: logger.info("Starte Force-Network-Refresh für alle Drucker") # Alle Caches invalidieren self.invalidate_all_caches() # Tapo-Controller Cache leeren falls vorhanden try: from utils.hardware_integration import tapo_controller if tapo_controller and hasattr(tapo_controller, 'clear_cache'): tapo_controller.clear_cache() logger.debug("Tapo-Controller Cache geleert") except Exception as e: logger.warning(f"Tapo-Controller Cache konnte nicht geleert werden: {str(e)}") # Frischen Status für alle Drucker abrufen fresh_status = self.get_all_printer_status(force_refresh=True) # Ergebnisse zusammenfassen results = { "success": True, "timestamp": datetime.now().isoformat(), "printers_refreshed": len(fresh_status), "printers": fresh_status, "message": f"Netzwerk-Status für {len(fresh_status)} Drucker erfolgreich aktualisiert" } logger.info(f"Force-Network-Refresh abgeschlossen: {len(fresh_status)} Drucker aktualisiert") return results except Exception as e: logger.error(f"Fehler beim Force-Network-Refresh: {str(e)}") return { "success": False, "error": str(e), "timestamp": datetime.now().isoformat(), "message": "Fehler beim Aktualisieren der Netzwerk-Status" } def _get_status_color(self, status: str) -> str: """Gibt die Farbe für einen Status zurück""" colors = { "scheduled": "#3788d8", "running": "#28a745", "finished": "#6c757d", "aborted": "#dc3545" } return colors.get(status, "#6c757d") # Globale Instanz tapo_status_manager = TapoStatusManager() def get_tapo_status_manager() -> TapoStatusManager: """Gibt die globale TapoStatusManager-Instanz zurück""" return tapo_status_manager