# app.py import os import json import logging import shutil from logging.handlers import TimedRotatingFileHandler from datetime import datetime, timedelta from flask import Flask, render_template, redirect, url_for, send_from_directory, request, jsonify # Logging-Verzeichnis (muss VOR cleanup_old_log_dirs definiert werden!) LOG_DIR = 'logs' os.makedirs(LOG_DIR, exist_ok=True) # ── Cleanup Log-Files ──────────────────────────────────────────────────────────────── def cleanup_old_log_dirs(max_age_days=90): """Löscht Monatsordner in logs/, die älter als max_age_days sind""" if not os.path.exists(LOG_DIR): return cutoff_date = datetime.now() - timedelta(days=max_age_days) cutoff_str = cutoff_date.strftime('%Y-%m') for subdir in os.listdir(LOG_DIR): subdir_path = os.path.join(LOG_DIR, subdir) if os.path.isdir(subdir_path): try: # subdir ist z. B. "2025-11" subdir_date = datetime.strptime(subdir, '%Y-%m') if subdir_date < cutoff_date: logger.info(f"Lösche alten Log-Ordner: {subdir_path}") shutil.rmtree(subdir_path) except ValueError: # Ungültiges Datumsformat → überspringen pass except Exception as e: logger.error(f"Fehler beim Löschen von {subdir_path}: {e}") # ── Bluetooth ──────────────────────────────────────────────────────────────── from mkconnect.mouldking.MouldKing import MouldKing from mkconnect.tracer.TracerConsole import TracerConsole from mkconnect.advertiser.AdvertiserBTSocket import AdvertiserBTSocket app = Flask(__name__) app.config['CONFIG_DIR'] = 'configs' os.makedirs(app.config['CONFIG_DIR'], exist_ok=True) # ── Logging ──────────────────────────────────────────────────────────────── # Täglicher Unterordner today = datetime.now().strftime('%Y-%m') current_log_subdir = os.path.join(LOG_DIR, today) os.makedirs(current_log_subdir, exist_ok=True) # Basis-Dateiname (ohne Endung) base_filename = os.path.join(current_log_subdir, datetime.now().strftime('%d')) # Handler für INFO+ info_handler = logging.FileHandler(f"{base_filename}-info.log") info_handler.setLevel(logging.INFO) info_handler.setFormatter(logging.Formatter( '%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S' )) # Handler für WARNING+ (inkl. ERROR, CRITICAL) error_handler = logging.FileHandler(f"{base_filename}-error.log") error_handler.setLevel(logging.WARNING) error_handler.setFormatter(logging.Formatter( '%(asctime)s | %(levelname)-8s | %(message)s\n%(pathname)s:%(lineno)d\n', datefmt='%Y-%m-%d %H:%M:%S' )) # Logger konfigurieren logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # DEBUG, damit alles durchkommt logger.handlers.clear() # Alte Handler entfernen logger.addHandler(info_handler) logger.addHandler(error_handler) # Optional: Auch in Konsole console = logging.StreamHandler() console.setLevel(logging.INFO) console.setFormatter(info_handler.formatter) logger.addHandler(console) logger.info("Logging getrennt initialisiert: info.log + error.log") # Bluetooth-Komponenten (einmalig initialisieren) tracer = TracerConsole() advertiser = AdvertiserBTSocket() # ← ohne tracer, wie korrigiert # Globale Zustände (für Einzelbenutzer / Entwicklung – später Session/DB) current_config = None current_hub: MouldKing | None = None current_module = None # Module6_0 oder Module4_0 current_device = None # Device0/1/2 je nach hub_id import threading import time # Globale Variable für den letzten erfolgreichen Check (optional) last_successful_check = time.time() def connection_monitor(): """Background-Thread: Prüft alle 5 Sekunden, ob der Hub noch antwortet""" global current_device, current_module, current_hub, last_successful_check while True: if current_device is not None: try: # Harter Test: Kanal 0 kurz auf 0.1 und zurück auf 0.0 setzen current_device.SetChannel(0, 0.1) time.sleep(0.05) # winzige Pause current_device.SetChannel(0, 0.0) last_successful_check = time.time() logger.debug("Monitor: Hub antwortet → SetChannel-Test OK") except Exception as e: logger.warning(f"Monitor: Hub scheint weg zu sein: {str(e)}") # Sauber trennen try: current_device.Disconnect() except: pass current_device = None current_module = None current_hub = None # Optional: Frontend benachrichtigen (z. B. über WebSocket später) time.sleep(5) # Prüfintervall: 5 Sekunden # Thread starten (daemon=True → beendet sich mit der App) monitor_thread = threading.Thread(target=connection_monitor, daemon=True) monitor_thread.start() logger.info("Background-Monitor für Hub-Verbindung gestartet") def load_configs(): configs = [] for filename in os.listdir(app.config['CONFIG_DIR']): if filename.lower().endswith('.json'): path = os.path.join(app.config['CONFIG_DIR'], filename) try: with open(path, 'r', encoding='utf-8') as f: data = json.load(f) data['filename'] = filename data.setdefault('name', filename.replace('.json', '').replace('_', ' ').title()) configs.append(data) except Exception as e: logger.error(f"Fehler beim Laden {filename}: {e}") return sorted(configs, key=lambda x: x.get('name', '')) @app.route('/') def index(): return render_template('index.html', configs=load_configs()) """ @app.route('/load_config/') def load_config(filename): global current_config path = os.path.join(app.config['CONFIG_DIR'], filename) if not os.path.exists(path): return "Datei nicht gefunden", 404 try: with open(path, 'r', encoding='utf-8') as f: current_config = json.load(f) current_config['filename'] = filename logger.info(f"Config geladen: {filename}") return redirect(url_for('control_page')) except Exception as e: logger.error(f"Config-Ladefehler {filename}: {e}") return "Fehler beim Laden", 500 """ @app.route('/load_config/') def load_config(filename): global current_config path = os.path.join(app.config['CONFIG_DIR'], filename) if not os.path.exists(path): logger.error(f"Config nicht gefunden: {path}") return "Konfiguration nicht gefunden", 404 try: with open(path, 'r', encoding='utf-8') as f: current_config = json.load(f) current_config['filename'] = filename logger.info(f"Config erfolgreich geladen: {filename} mit {len(current_config.get('channels', []))} Kanälen") return redirect(url_for('control_page')) except json.JSONDecodeError as e: logger.error(f"Ungültiges JSON in {filename}: {e}") return "Ungültiges JSON-Format in der Konfigurationsdatei", 500 except Exception as e: logger.error(f"Ladefehler {filename}: {e}") return "Fehler beim Laden der Konfiguration", 500 """ @app.route('/control') def control_page(): if current_config is None: return redirect(url_for('index')) return render_template('control.html', config=current_config) """ @app.route('/control') def control_page(): if current_config is None: logger.warning("current_config ist None → Redirect zu index") return redirect(url_for('index')) logger.info(f"Übergebe config an Template: {current_config}") print("DEBUG: config hat channels?", 'channels' in current_config, len(current_config.get('channels', []))) return render_template('control.html', config=current_config) @app.route('/soundboard') def soundboard(): if current_config is None or 'sounds' not in current_config: return redirect(url_for('index')) sounds = current_config.get('sounds', []) return render_template('soundboard.html', sounds=sounds, config=current_config) # ── Admin ──────────────────────────────────────────────────────────────────── @app.route('/admin') def admin(): configs = load_configs() return render_template('admin.html', configs=configs) @app.route('/admin/edit/', methods=['GET', 'POST']) def admin_edit_config(filename): path = os.path.join(app.config['CONFIG_DIR'], filename) if request.method == 'POST': try: new_content = request.form.get('config_content') if not new_content: raise ValueError("Kein Inhalt übermittelt") # Validierung: versuche zu parsen json.loads(new_content) with open(path, 'w', encoding='utf-8') as f: f.write(new_content) logger.info(f"Config {filename} erfolgreich gespeichert") return redirect(url_for('admin')) except json.JSONDecodeError as e: logger.error(f"Ungültiges JSON in {filename}: {e}") error_msg = f"Ungültiges JSON: {str(e)}" except Exception as e: logger.error(f"Speicherfehler {filename}: {e}") error_msg = f"Fehler beim Speichern: {str(e)}" # Bei Fehler: zurück zum Formular mit Fehlermeldung with open(path, 'r', encoding='utf-8') as f: content = f.read() return render_template('admin_edit.html', filename=filename, content=content, error=error_msg) # GET: Formular laden if not os.path.exists(path): return "Konfiguration nicht gefunden", 404 with open(path, 'r', encoding='utf-8') as f: content = f.read() return render_template('admin_edit.html', filename=filename, content=content) @app.route('/admin/delete/', methods=['POST']) def admin_delete_config(filename): path = os.path.join(app.config['CONFIG_DIR'], filename) if os.path.exists(path): try: os.remove(path) logger.info(f"Config gelöscht: {filename}") return jsonify({"success": True, "message": f"{filename} gelöscht"}) except Exception as e: logger.error(f"Löschfehler {filename}: {e}") return jsonify({"success": False, "message": str(e)}), 500 return jsonify({"success": False, "message": "Datei nicht gefunden"}), 404 @app.route('/admin/logs') @app.route('/admin/logs/') def admin_logs(date=None): if date is None: date = datetime.now().strftime('%d') today = datetime.now().strftime('%Y-%m') # ← Hier definieren! current_month = datetime.now().strftime('%Y-%m') log_dir = os.path.join(LOG_DIR, today) if not os.path.exists(log_dir): return render_template('admin_logs.html', logs="Keine Logs für diesen Tag.", date=date, dates=[], today=today) # Verfügbare Tage ... dates = sorted([ f.split('-')[0] for f in os.listdir(log_dir) if f.endswith('-info.log') or f.endswith('-error.log') ], reverse=True) # Logs laden ... info_path = os.path.join(log_dir, f"{date}-info.log") error_path = os.path.join(log_dir, f"{date}-error.log") logs = [] if os.path.exists(info_path): with open(info_path, 'r', encoding='utf-8') as f: logs.append("=== INFO-LOG ===\n" + f.read()) if os.path.exists(error_path): with open(error_path, 'r', encoding='utf-8') as f: logs.append("=== ERROR-LOG ===\n" + f.read()) log_content = "\n\n".join(logs) if logs else "Keine Logs für diesen Tag." return render_template('admin_logs.html', logs=log_content, date=date, dates=dates, today=current_month) @app.route('/configs/') def serve_config_file(filename): return send_from_directory(app.config['CONFIG_DIR'], filename) # ── API ────────────────────────────────────────────────────────────────────── @app.route('/api/connect', methods=['POST']) def api_connect(): global current_hub, current_module, current_device if current_config is None: return jsonify({"success": False, "message": "Keine Konfiguration geladen"}), 400 try: hub_id = int(current_config.get('hub_id', 0)) hub_type = current_config.get('hub_type', '6channel') # '4channel' oder '6channel' # Alte Verbindung sauber trennen if current_device is not None: try: current_device.Disconnect() except: pass if current_module is not None: # ggf. Stop vor Disconnect try: current_device.Stop() except: pass # MouldKing neu initialisieren mk = MouldKing() mk.SetAdvertiser(advertiser) try: mk.SetTracer(tracer) except: pass # Modul holen if hub_type.lower() in ['6channel', '6']: current_module = mk.Module6_0() else: current_module = mk.Module4_0() # Device auswählen (hub_id = 0,1,2 → Device0,1,2) device_attr = f'Device{hub_id}' if not hasattr(current_module, device_attr): raise ValueError(f"Hub-ID {hub_id} nicht unterstützt (max 0-2)") current_device = getattr(current_module, device_attr) # Verbinden current_device.Connect() current_hub = mk # falls später noch gebraucht logger.info(f"Verbunden: {hub_type} Hub-ID {hub_id} → {device_attr}") return jsonify({"success": True, "message": f"Hub {hub_id} ({hub_type}) verbunden"}) except Exception as e: logger.exception("Connect-Fehler") return jsonify({"success": False, "message": f"Verbindungsfehler: {str(e)}"}), 500 @app.route('/api/status', methods=['GET']) def api_status(): global current_device if current_device is not None: return jsonify({ "connected": True, "message": "Verbunden" }) else: return jsonify({ "connected": False, "message": "Keine aktive Verbindung" }), 200 @app.route('/api/control', methods=['POST']) def api_control(): global current_device if current_device is None: return jsonify({"success": False, "message": "Nicht verbunden"}), 400 try: data = request.get_json() port = data['port'] # 'A', 'B', ... value = float(data['value']) # Port → channelId mappen (A=0, B=1, ...) channel_map = {'A':0, 'B':1, 'C':2, 'D':3, 'E':4, 'F':5} if isinstance(port, str): port_upper = port.upper() if port_upper in channel_map: channel_id = channel_map[port_upper] else: raise ValueError(f"Ungültiger Port: {port}") else: channel_id = int(port) # Config-spezifische Anpassungen (invert, negative_only, on/off-Werte) ch_cfg = next((c for c in current_config['channels'] if c['port'].upper() == port.upper()), None) if ch_cfg: if ch_cfg.get('invert', False): value = -value if ch_cfg.get('negative_only', False) and value >= 0: value = -abs(value) if ch_cfg['type'] != 'motor': value = ch_cfg.get('on_value', 1.0) if value != 0 else ch_cfg.get('off_value', 0.0) # Echter Aufruf! current_device.SetChannel(channel_id, value) logger.info(f"SetChannel({channel_id}, {value:.2f}) → Port {port}") return jsonify({"success": True}) except Exception as e: logger.exception("Control-Fehler") return jsonify({"success": False, "message": str(e)}), 500 @app.route('/api/stop_all', methods=['POST']) def api_stop_all(): global current_device if current_device is None: return jsonify({"success": False, "message": "Nicht verbunden"}), 400 try: # Nur stoppen – KEIN Disconnect! current_device.Stop() logger.info("Alle Kanäle gestoppt (Verbindung bleibt bestehen)") return jsonify({"success": True, "message": "Alle Kanäle gestoppt"}) except Exception as e: logger.exception("Stop-Fehler") return jsonify({"success": False, "message": str(e)}), 500 @app.route('/api/reconnect', methods=['POST']) def api_reconnect(): global current_device, current_module, current_hub if current_config is None: return jsonify({"success": False, "message": "Keine Konfiguration geladen"}), 400 try: # Alte Verbindung sauber beenden, falls nötig if current_device is not None: try: current_device.Disconnect() except: pass # Neu verbinden (kopierter Code aus api_connect) hub_id = int(current_config.get('hub_id', 0)) hub_type = current_config.get('hub_type', '6channel') mk = MouldKing() mk.SetAdvertiser(advertiser) try: mk.SetTracer(tracer) except: pass if hub_type.lower() in ['6channel', '6']: current_module = mk.Module6_0() else: current_module = mk.Module4_0() device_attr = f'Device{hub_id}' if not hasattr(current_module, device_attr): raise ValueError(f"Hub-ID {hub_id} nicht unterstützt") current_device = getattr(current_module, device_attr) current_device.Connect() current_hub = mk logger.info(f"Re-Connect erfolgreich: Hub {hub_id}") return jsonify({"success": True, "message": "Verbindung wiederhergestellt"}) except Exception as e: logger.exception("Re-Connect-Fehler") return jsonify({"success": False, "message": str(e)}), 500 import pygame import threading # pygame einmalig initialisieren (am besten global) pygame.mixer.init() @app.route('/api/play_sound', methods=['POST']) def api_play_sound(): try: data = request.get_json() sound_id = data.get('sound_id') if not sound_id: return jsonify({"success": False, "message": "Kein sound_id angegeben"}), 400 # Sound aus aktueller Config suchen if current_config is None or 'sounds' not in current_config: return jsonify({"success": False, "message": "Keine Sounds in der aktuellen Konfiguration"}), 400 sound_entry = next((s for s in current_config['sounds'] if s['id'] == sound_id), None) if not sound_entry: return jsonify({"success": False, "message": f"Sound mit ID '{sound_id}' nicht gefunden"}), 404 file_path = os.path.join('sounds', sound_entry['file']) if not os.path.exists(file_path): logger.error(f"Sound-Datei nicht gefunden: {file_path}") return jsonify({"success": False, "message": "Sound-Datei nicht gefunden"}), 404 # Sound in separatem Thread abspielen (blockiert nicht) def play(): try: pygame.mixer.music.load(file_path) pygame.mixer.music.play() while pygame.mixer.music.get_busy(): time.sleep(0.1) except Exception as e: logger.error(f"Fehler beim Abspielen von {file_path}: {e}") threading.Thread(target=play, daemon=True).start() logger.info(f"Spiele Sound: {sound_entry['name']} ({sound_id})") return jsonify({"success": True, "message": f"Spiele: {sound_entry['name']}"}) except Exception as e: logger.exception("Play-Sound-Fehler") return jsonify({"success": False, "message": str(e)}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) # Beim Start aufrufen cleanup_old_log_dirs(90) logger.info("Alte Log-Ordner bereinigt") def daily_cleanup(): while True: cleanup_old_log_dirs(90) time.sleep(86400) # 24 Stunden cleanup_thread = threading.Thread(target=daily_cleanup, daemon=True) cleanup_thread.start()