📝 Commit Details:

This commit is contained in:
2025-05-31 22:40:29 +02:00
parent 91b1886dde
commit df8fb197c0
14061 changed files with 997277 additions and 103548 deletions

View File

@@ -0,0 +1,729 @@
import threading
import time
import logging
from typing import Dict, Callable, Any, List, Optional, Union
from datetime import datetime, timedelta
from PyP100 import PyP110
from sqlalchemy.orm import joinedload
from utils.logging_config import get_logger
from models import Job, Printer, get_db_session
from config.settings import TAPO_USERNAME, TAPO_PASSWORD
# Lazy logger initialization
_logger = None
def get_scheduler_logger():
"""Lazy initialization of the scheduler logger."""
global _logger
if _logger is None:
_logger = get_logger("scheduler")
return _logger
class BackgroundTaskScheduler:
"""
Ein fortschrittlicher Hintergrund-Task-Scheduler, der registrierbare Worker-Funktionen unterstützt.
Tasks können als Platzhalter registriert und später konfiguriert werden.
"""
def __init__(self):
self._tasks: Dict[str, Dict[str, Any]] = {}
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._running = False
self._start_time: Optional[datetime] = None
self.logger = get_scheduler_logger()
def register_task(self,
task_id: str,
func: Callable,
interval: int = 60,
args: List = None,
kwargs: Dict = None,
enabled: bool = True) -> bool:
"""
Registriert eine neue Hintergrund-Task.
Args:
task_id: Eindeutige ID für die Task
func: Die auszuführende Funktion
interval: Intervall in Sekunden zwischen den Ausführungen
args: Positionsargumente für die Funktion
kwargs: Schlüsselwortargumente für die Funktion
enabled: Ob die Task aktiviert sein soll
Returns:
bool: True wenn erfolgreich, False wenn die ID bereits existiert
"""
if task_id in self._tasks:
self.logger.error(f"Task mit ID {task_id} existiert bereits")
return False
self._tasks[task_id] = {
"func": func,
"interval": interval,
"args": args or [],
"kwargs": kwargs or {},
"enabled": enabled,
"last_run": None,
"next_run": datetime.now() if enabled else None
}
self.logger.info(f"Task {task_id} registriert: Intervall {interval}s, Enabled: {enabled}")
return True
def update_task(self,
task_id: str,
interval: Optional[int] = None,
args: Optional[List] = None,
kwargs: Optional[Dict] = None,
enabled: Optional[bool] = None) -> bool:
"""
Aktualisiert die Konfiguration einer bestehenden Task.
Args:
task_id: ID der zu aktualisierenden Task
interval: Neues Intervall in Sekunden
args: Neue Positionsargumente
kwargs: Neue Schlüsselwortargumente
enabled: Neuer Aktivierungsstatus
Returns:
bool: True wenn erfolgreich, False wenn die ID nicht existiert
"""
if task_id not in self._tasks:
self.logger.error(f"Task mit ID {task_id} existiert nicht")
return False
task = self._tasks[task_id]
if interval is not None:
task["interval"] = interval
if args is not None:
task["args"] = args
if kwargs is not None:
task["kwargs"] = kwargs
if enabled is not None and enabled != task["enabled"]:
task["enabled"] = enabled
if enabled:
task["next_run"] = datetime.now()
else:
task["next_run"] = None
self.logger.info(f"Task {task_id} aktualisiert: Intervall {task['interval']}s, Enabled: {task['enabled']}")
return True
def remove_task(self, task_id: str) -> bool:
"""
Entfernt eine Task aus dem Scheduler.
Args:
task_id: ID der zu entfernenden Task
Returns:
bool: True wenn erfolgreich, False wenn die ID nicht existiert
"""
if task_id not in self._tasks:
self.logger.error(f"Task mit ID {task_id} existiert nicht")
return False
del self._tasks[task_id]
self.logger.info(f"Task {task_id} entfernt")
return True
def get_task_info(self, task_id: Optional[str] = None) -> Union[Dict, List[Dict]]:
"""
Gibt Informationen zu einer Task oder allen Tasks zurück.
Args:
task_id: ID der Task oder None für alle Tasks
Returns:
Dict oder List: Task-Informationen
"""
if task_id is not None:
if task_id not in self._tasks:
return {}
task = self._tasks[task_id]
return {
"id": task_id,
"interval": task["interval"],
"enabled": task["enabled"],
"last_run": task["last_run"].isoformat() if task["last_run"] else None,
"next_run": task["next_run"].isoformat() if task["next_run"] else None
}
return [
{
"id": tid,
"interval": task["interval"],
"enabled": task["enabled"],
"last_run": task["last_run"].isoformat() if task["last_run"] else None,
"next_run": task["next_run"].isoformat() if task["next_run"] else None
}
for tid, task in self._tasks.items()
]
def get_tasks(self) -> Dict[str, Dict[str, Any]]:
"""
Gibt alle Tasks mit ihren Konfigurationen zurück.
Returns:
Dict: Dictionary mit Task-IDs als Schlüssel und Task-Konfigurationen als Werte
"""
return {
task_id: {
"interval": task["interval"],
"enabled": task["enabled"],
"last_run": task["last_run"].isoformat() if task["last_run"] else None,
"next_run": task["next_run"].isoformat() if task["next_run"] else None
}
for task_id, task in self._tasks.items()
}
def get_uptime(self) -> Optional[str]:
"""
Gibt die Laufzeit des Schedulers seit dem Start zurück.
Returns:
str: Formatierte Laufzeit oder None, wenn der Scheduler nicht läuft
"""
if not self._running or not self._start_time:
return None
uptime = datetime.now() - self._start_time
days = uptime.days
hours, remainder = divmod(uptime.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if days > 0:
return f"{days} Tage, {hours} Stunden, {minutes} Minuten"
elif hours > 0:
return f"{hours} Stunden, {minutes} Minuten"
else:
return f"{minutes} Minuten, {seconds} Sekunden"
def start(self) -> bool:
"""
Startet den Scheduler.
Returns:
bool: True wenn erfolgreich gestartet, False wenn bereits läuft
"""
if self._running:
self.logger.warning("Scheduler läuft bereits")
return False
self._stop_event.clear()
self._thread = threading.Thread(target=self._run)
self._thread.daemon = True
self._thread.start()
self._running = True
self._start_time = datetime.now()
self.logger.info("Scheduler gestartet")
return True
def stop(self) -> bool:
"""
Stoppt den Scheduler.
Returns:
bool: True wenn erfolgreich gestoppt, False wenn nicht läuft
"""
if not self._running:
self.logger.warning("Scheduler läuft nicht")
return False
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5.0)
self._running = False
self._start_time = None
self.logger.info("Scheduler gestoppt")
return True
def is_running(self) -> bool:
"""
Prüft, ob der Scheduler läuft.
Returns:
bool: True wenn der Scheduler läuft, sonst False
"""
return self._running
def _run(self) -> None:
"""Hauptloop des Schedulers."""
self.logger.info("Scheduler-Thread gestartet")
while not self._stop_event.is_set():
now = datetime.now()
for task_id, task in self._tasks.items():
if not task["enabled"] or not task["next_run"]:
continue
if now >= task["next_run"]:
try:
self.logger.debug(f"Führe Task {task_id} aus")
task["func"](*task["args"], **task["kwargs"])
task["last_run"] = now
task["next_run"] = now + timedelta(seconds=task["interval"])
self.logger.debug(f"Task {task_id} erfolgreich ausgeführt, nächste Ausführung: {task['next_run']}")
except Exception as e:
self.logger.error(f"Fehler bei Ausführung von Task {task_id}: {str(e)}")
# Trotzdem nächste Ausführung planen
task["next_run"] = now + timedelta(seconds=task["interval"])
# Schlafenszeit berechnen (1 Sekunde oder weniger)
time.sleep(1)
self.logger.info("Scheduler-Thread beendet")
def toggle_plug(self, ip: str, state: bool, username: str = None, password: str = None) -> bool:
"""
Schaltet eine TP-Link Tapo P100/P110-Steckdose ein oder aus.
Args:
ip: IP-Adresse der Steckdose
state: True = Ein, False = Aus
username: Benutzername für die Steckdose (wird überschrieben mit globalen Credentials)
password: Passwort für die Steckdose (wird überschrieben mit globalen Credentials)
Returns:
bool: True wenn erfolgreich geschaltet
"""
try:
# PyP100 importieren
try:
from PyP100 import PyP100
except ImportError:
self.logger.error("❌ PyP100-Modul nicht installiert - Steckdose kann nicht geschaltet werden")
return False
# IMMER globale Anmeldedaten verwenden (da diese funktionieren)
from config.settings import TAPO_USERNAME, TAPO_PASSWORD
username = TAPO_USERNAME
password = TAPO_PASSWORD
self.logger.debug(f"🔧 Verwende globale Tapo-Anmeldedaten für {ip}")
# P100-Verbindung herstellen (P100 statt P110 verwenden)
p100 = PyP100.P100(ip, username, password)
# Handshake und Login durchführen
p100.handshake()
p100.login()
# Steckdose schalten
if state:
p100.turnOn()
self.logger.info(f"✅ Tapo-Steckdose {ip} erfolgreich eingeschaltet")
else:
p100.turnOff()
self.logger.info(f"✅ Tapo-Steckdose {ip} erfolgreich ausgeschaltet")
return True
except Exception as e:
action = "ein" if state else "aus"
self.logger.error(f"❌ Fehler beim {action}schalten der Tapo-Steckdose {ip}: {str(e)}")
return False
def toggle_printer_plug(self, printer_id: int, state: bool) -> bool:
"""
Schaltet die Steckdose eines Druckers ein oder aus mit korrektem Status-Mapping:
- Steckdose AUS = Drucker ONLINE (bereit zum Drucken)
- Steckdose AN = Drucker PRINTING (druckt gerade)
Args:
printer_id: ID des Druckers
state: True für ein, False für aus
Returns:
bool: True wenn erfolgreich, False wenn fehlgeschlagen
"""
try:
# Drucker aus Datenbank holen
db_session = get_db_session()
printer = db_session.get(Printer, printer_id)
if not printer:
self.logger.error(f"❌ Drucker mit ID {printer_id} nicht gefunden")
db_session.close()
return False
# Konfiguration validieren
if not printer.plug_ip:
self.logger.error(f"❌ Unvollständige Steckdosen-Konfiguration für Drucker {printer.name}")
db_session.close()
return False
# Steckdose schalten
success = self.toggle_plug(
ip=printer.plug_ip,
state=state,
username=printer.plug_username, # Wird überschrieben mit globalen Credentials
password=printer.plug_password # Wird überschrieben mit globalen Credentials
)
if success:
# Status in Datenbank aktualisieren entsprechend der neuen Logik
if state:
# Steckdose eingeschaltet = Drucker druckt
printer.status = "printing"
self.logger.info(f"🖨️ Drucker {printer.name}: Status auf 'printing' gesetzt (Steckdose eingeschaltet)")
else:
# Steckdose ausgeschaltet = Drucker bereit
printer.status = "online"
self.logger.info(f"✅ Drucker {printer.name}: Status auf 'online' gesetzt (Steckdose ausgeschaltet - bereit)")
printer.last_checked = datetime.now()
db_session.commit()
self.logger.info(f"✅ Status für Drucker {printer.name} erfolgreich aktualisiert")
db_session.close()
return success
except Exception as e:
action = "ein" if state else "aus"
self.logger.error(f"❌ Fehler beim {action}schalten der Steckdose für Drucker {printer_id}: {str(e)}")
try:
db_session.close()
except:
pass
return False
def _check_jobs(self) -> None:
"""
Überprüft und verwaltet Druckjobs mit intelligentem Power Management:
- Startet anstehende Jobs (geplante Jobs)
- Beendet abgelaufene Jobs (schaltet Steckdose aus)
- Schaltet Drucker automatisch aus bei Leerlauf
- Schaltet Drucker automatisch ein bei neuen Jobs
"""
db_session = get_db_session()
try:
now = datetime.now()
# 1. Anstehende Jobs starten (geplante Jobs)
pending_jobs = db_session.query(Job).filter(
Job.status == "scheduled",
Job.start_at <= now
).all()
for job in pending_jobs:
self.logger.info(f"🚀 Starte geplanten Job {job.id}: {job.name}")
# Steckdose einschalten
if self.toggle_printer_plug(job.printer_id, True):
# Job als laufend markieren
job.status = "running"
db_session.commit()
self.logger.info(f"✅ Job {job.id} gestartet - Drucker eingeschaltet")
else:
self.logger.error(f"❌ Konnte Steckdose für Job {job.id} nicht einschalten")
# 2. Sofort-Jobs starten (Jobs die bereits hätten starten sollen)
immediate_jobs = db_session.query(Job).filter(
Job.status == "waiting_for_printer",
Job.start_at <= now
).all()
for job in immediate_jobs:
self.logger.info(f"⚡ Starte Sofort-Job {job.id}: {job.name}")
# Steckdose einschalten
if self.toggle_printer_plug(job.printer_id, True):
# Job als laufend markieren
job.status = "running"
db_session.commit()
self.logger.info(f"✅ Sofort-Job {job.id} gestartet - Drucker automatisch eingeschaltet")
else:
self.logger.error(f"❌ Konnte Steckdose für Sofort-Job {job.id} nicht einschalten")
# 3. Abgelaufene Jobs beenden
running_jobs = db_session.query(Job).filter(
Job.status == "running",
Job.end_at <= now
).all()
for job in running_jobs:
self.logger.info(f"🏁 Beende Job {job.id}: {job.name}")
# Job als beendet markieren
job.status = "finished"
job.actual_end_time = now
db_session.commit()
self.logger.info(f"✅ Job {job.id} beendet")
# Prüfen ob weitere Jobs für diesen Drucker anstehen
pending_jobs_for_printer = db_session.query(Job).filter(
Job.printer_id == job.printer_id,
Job.status.in_(["scheduled", "running", "waiting_for_printer"])
).count()
if pending_jobs_for_printer == 0:
# Keine weiteren Jobs - Drucker ausschalten (Leerlauf-Management)
if self.toggle_printer_plug(job.printer_id, False):
self.logger.info(f"💤 Drucker {job.printer_id} automatisch ausgeschaltet - Leerlauf erkannt")
else:
self.logger.warning(f"⚠️ Konnte Drucker {job.printer_id} nicht ausschalten")
else:
self.logger.info(f"🔄 Drucker {job.printer_id} bleibt eingeschaltet - {pending_jobs_for_printer} weitere Jobs anstehend")
# 4. Intelligentes Leerlauf-Management für alle aktiven Drucker
active_printers = db_session.query(Printer).filter(
Printer.active == True,
Printer.plug_ip.isnot(None),
Printer.status == "online"
).all()
for printer in active_printers:
# Prüfen ob Jobs für diesen Drucker anstehen
active_jobs_count = db_session.query(Job).filter(
Job.printer_id == printer.id,
Job.status.in_(["scheduled", "running", "waiting_for_printer"])
).count()
if active_jobs_count == 0:
# Keine Jobs anstehend - prüfen ob Drucker schon längere Zeit im Leerlauf ist
if printer.last_checked:
idle_time = now - printer.last_checked
# Drucker ausschalten wenn länger als 5 Minuten im Leerlauf
if idle_time.total_seconds() > 300: # 5 Minuten
if self.toggle_printer_plug(printer.id, False):
self.logger.info(f"💤 Drucker {printer.name} nach {idle_time.total_seconds()//60:.0f} Min Leerlauf ausgeschaltet")
else:
self.logger.warning(f"⚠️ Konnte Drucker {printer.name} nach Leerlauf nicht ausschalten")
except Exception as e:
self.logger.error(f"❌ Fehler bei Überprüfung der Jobs: {str(e)}")
try:
db_session.rollback()
except:
pass
finally:
db_session.close()
def handle_immediate_job(self, job_id: int) -> bool:
"""
Behandelt einen Job sofort (für Sofort-Start bei Job-Erstellung).
Args:
job_id: ID des zu startenden Jobs
Returns:
bool: True wenn Job erfolgreich gestartet wurde
"""
db_session = get_db_session()
try:
now = datetime.now()
# Job aus Datenbank laden
job = db_session.get(Job, job_id)
if not job:
self.logger.error(f"❌ Job {job_id} nicht gefunden")
db_session.close()
return False
# Nur Jobs behandeln die sofort starten sollen
if job.start_at > now:
self.logger.info(f"⏰ Job {job_id} ist für später geplant ({job.start_at}) - kein Sofort-Start")
db_session.close()
return False
# Nur Jobs in passenden Status
if job.status not in ["scheduled", "waiting_for_printer"]:
self.logger.info(f" Job {job_id} hat Status '{job.status}' - kein Sofort-Start nötig")
db_session.close()
return False
self.logger.info(f"⚡ Starte Sofort-Job {job_id}: {job.name} für Drucker {job.printer_id}")
# Steckdose einschalten
if self.toggle_printer_plug(job.printer_id, True):
# Job als laufend markieren
job.status = "running"
db_session.commit()
db_session.close()
self.logger.info(f"✅ Sofort-Job {job_id} erfolgreich gestartet - Drucker automatisch eingeschaltet")
return True
else:
self.logger.error(f"❌ Konnte Steckdose für Sofort-Job {job_id} nicht einschalten")
db_session.close()
return False
except Exception as e:
self.logger.error(f"❌ Fehler beim Starten von Sofort-Job {job_id}: {str(e)}")
try:
db_session.rollback()
db_session.close()
except:
pass
return False
def check_and_manage_printer_power(self, printer_id: int) -> bool:
"""
Prüft und verwaltet die Stromversorgung eines spezifischen Druckers.
Args:
printer_id: ID des zu prüfenden Druckers
Returns:
bool: True wenn Power-Management erfolgreich
"""
db_session = get_db_session()
try:
now = datetime.now()
# Drucker laden
printer = db_session.get(Printer, printer_id)
if not printer or not printer.plug_ip:
db_session.close()
return False
# Aktive Jobs für diesen Drucker prüfen
active_jobs = db_session.query(Job).filter(
Job.printer_id == printer_id,
Job.status.in_(["scheduled", "running", "waiting_for_printer"])
).all()
current_jobs = [job for job in active_jobs if job.start_at <= now]
future_jobs = [job for job in active_jobs if job.start_at > now]
if current_jobs:
# Jobs laufen oder sollten laufen - Drucker einschalten
self.logger.info(f"🔋 Drucker {printer.name} benötigt Strom - {len(current_jobs)} aktive Jobs")
success = self.toggle_printer_plug(printer_id, True)
# Jobs von waiting_for_printer auf running umstellen
for job in current_jobs:
if job.status == "waiting_for_printer":
job.status = "running"
self.logger.info(f"🚀 Job {job.id} von 'waiting_for_printer' auf 'running' umgestellt")
db_session.commit()
db_session.close()
return success
elif future_jobs:
# Nur zukünftige Jobs - Drucker kann ausgeschaltet bleiben
next_job_time = min(job.start_at for job in future_jobs)
time_until_next = (next_job_time - now).total_seconds() / 60
self.logger.info(f"⏳ Drucker {printer.name} hat {len(future_jobs)} zukünftige Jobs, nächster in {time_until_next:.1f} Min")
# Drucker ausschalten wenn nächster Job erst in mehr als 10 Minuten
if time_until_next > 10:
success = self.toggle_printer_plug(printer_id, False)
db_session.close()
return success
else:
self.logger.info(f"🔄 Drucker {printer.name} bleibt eingeschaltet - nächster Job bald")
db_session.close()
return True
else:
# Keine Jobs - Drucker ausschalten (Leerlauf)
self.logger.info(f"💤 Drucker {printer.name} hat keine anstehenden Jobs - ausschalten")
success = self.toggle_printer_plug(printer_id, False)
db_session.close()
return success
except Exception as e:
self.logger.error(f"❌ Fehler beim Power-Management für Drucker {printer_id}: {str(e)}")
try:
db_session.close()
except:
pass
return False
def test_tapo_connection(ip_address: str, username: str = None, password: str = None) -> dict:
"""
Testet die Verbindung zu einer TP-Link Tapo P110-Steckdose.
Args:
ip_address: IP-Adresse der Steckdose
username: Benutzername für die Steckdose (optional)
password: Passwort für die Steckdose (optional)
Returns:
dict: Ergebnis mit Status und Informationen
"""
logger = get_logger("tapo")
result = {
"success": False,
"message": "",
"device_info": None,
"error": None
}
try:
# Importiere PyP100 für Tapo-Unterstützung
try:
from PyP100 import PyP100
except ImportError:
result["message"] = "PyP100-Modul nicht verfügbar"
result["error"] = "ModuleNotFound"
logger.error("PyP100-Modul nicht verfügbar - kann Tapo-Steckdosen nicht testen")
return result
# Verwende globale Anmeldedaten falls nicht angegeben
if not username or not password:
from config.settings import TAPO_USERNAME, TAPO_PASSWORD
username = TAPO_USERNAME
password = TAPO_PASSWORD
logger.debug(f"Verwende globale Tapo-Anmeldedaten für {ip_address}")
# TP-Link Tapo P100 Verbindung herstellen
p100 = PyP100.P100(ip_address, username, password)
p100.handshake() # Authentifizierung
p100.login() # Login
# Geräteinformationen abrufen
device_info = p100.getDeviceInfo()
result["success"] = True
result["message"] = "Verbindung erfolgreich"
result["device_info"] = device_info
logger.info(f"Tapo-Verbindung zu {ip_address} erfolgreich: {device_info.get('nickname', 'Unbekannt')}")
except Exception as e:
result["success"] = False
result["message"] = f"Verbindungsfehler: {str(e)}"
result["error"] = str(e)
logger.error(f"Fehler bei Tapo-Test zu {ip_address}: {str(e)}")
return result
# Scheduler-Instanz erzeugen
scheduler = BackgroundTaskScheduler()
# Standardaufgaben registrieren - reduziertes Intervall für bessere Reaktionszeit
scheduler.register_task("check_jobs", scheduler._check_jobs, interval=30)
# Alias für Kompatibilität
JobScheduler = BackgroundTaskScheduler
def get_job_scheduler() -> BackgroundTaskScheduler:
"""
Gibt den globalen Job-Scheduler zurück.
Returns:
BackgroundTaskScheduler: Der globale Scheduler
"""
return scheduler