# app/routes/api.py import os import time import threading import logging import random import json from collections import defaultdict, deque import pygame from flask import Blueprint, jsonify, request, current_app import app.state as state from app.bluetooth.manager import MouldKing, advertiser, tracer from app.utils.helpers import load_default_sounds, load_soundboard_configs, load_soundboard_config, read_default_theme from config import Config logger = logging.getLogger(__name__) audio_lock = threading.Lock() auto_random_lock = threading.Lock() auto_random_timer = None auto_random_params = {} AUTO_STATE_PATH = os.path.join(Config.LOG_DIR, 'auto_random_state.json') # Sound-Cache: path -> pygame.mixer.Sound loaded_sounds = {} random_history = defaultdict(deque) # sound_id -> deque[timestamps] DEFAULT_MAX_PER_HOUR = 2 DEFAULT_WINDOW_SECONDS = 3600 def _ensure_mixer(): from config import Config if not pygame.mixer.get_init(): pygame.mixer.pre_init(frequency=Config.AUDIO_FREQ, size=Config.AUDIO_SIZE, channels=Config.AUDIO_CHANNELS, buffer=Config.AUDIO_BUFFER) pygame.mixer.init() pygame.mixer.set_num_channels(Config.AUDIO_NUM_CHANNELS) def _load_sound(file_path): if file_path in loaded_sounds: return loaded_sounds[file_path] snd = pygame.mixer.Sound(file_path) loaded_sounds[file_path] = snd return snd def _play_sound_entry(sound_entry, channel_req=None, loop_req=0, sounds_dir=None): if sounds_dir is None: try: sounds_dir = current_app.config['SOUNDS_DIR'] except Exception: sounds_dir = Config.SOUNDS_DIR base_path = sound_entry.get('base_path') or sounds_dir file_path = os.path.join(base_path, 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 # Abspielen (serialisiert, aber mehrere Channels erlaubt) with audio_lock: _ensure_mixer() # Sound laden (Cache) snd = _load_sound(file_path) # Ziel-Channel bestimmen ch = None if channel_req not in [None, ""]: try: ch_id = int(channel_req) ch = pygame.mixer.Channel(ch_id) except Exception as e: logger.error(f"Ungültiger channel-Wert '{channel_req}': {e}") return jsonify({"success": False, "message": "Ungültiger Channel"}), 400 else: ch = pygame.mixer.find_channel(True) # zwingend freien nehmen if ch is None: return jsonify({"success": False, "message": "Kein freier Audio-Channel verfügbar"}), 503 # Loop-Wert interpretieren loops = -1 if loop_req in [True, "true", "True", -1, "loop", "1", 1] else 0 try: ch.play(snd, loops=loops) except Exception as e: logger.exception(f"Fehler beim Abspielen von {file_path}") return jsonify({"success": False, "message": str(e)}), 500 return None # Erfolg def _pick_random_sound(): if state.current_soundboard is None: return None, "Kein Soundboard geladen" rnd_list = state.current_soundboard.get('random_pool') or state.current_soundboard.get('sounds', []) if not rnd_list: return None, "Keine Random-Sounds definiert" now = time.time() candidates = [] for sound in rnd_list: sid = sound.get('id') or sound.get('file') max_per_hour, window_seconds = _get_limits(sound) dq = _prune_history(sid, now, window_seconds) if len(dq) < max_per_hour: candidates.append(sound) if not candidates: return None, "Limit erreicht (2x pro Stunde)" sound_entry = random.choice(candidates) sid = sound_entry.get('id') or sound_entry.get('file') random_history[sid].append(now) sound_entry.setdefault('id', sid) return sound_entry, None def _get_limits(sound_entry=None): """ Ermittelt Grenzen für Random-Wiedergabe. Priorität: pro Sound (max_per_hour, window_minutes) -> aktuelles Soundboard -> Defaults. """ max_per_hour = DEFAULT_MAX_PER_HOUR window_seconds = DEFAULT_WINDOW_SECONDS if state.current_soundboard: sb = state.current_soundboard max_per_hour = int(sb.get('random_limit_per_hour', max_per_hour)) window_minutes = sb.get('random_window_minutes') if window_minutes: window_seconds = int(window_minutes * 60) if sound_entry: if 'max_per_hour' in sound_entry: max_per_hour = int(sound_entry['max_per_hour']) if 'window_minutes' in sound_entry: window_seconds = int(sound_entry['window_minutes'] * 60) return max_per_hour, window_seconds def _auto_random_tick(): global auto_random_timer with auto_random_lock: if not state.auto_random_active: return imin = auto_random_params.get('imin', 5) imax = auto_random_params.get('imax', 10) # Play one random sound sound_entry, err = _pick_random_sound() if sound_entry: _play_sound_entry(sound_entry, sound_entry.get('channel'), sound_entry.get('loop'), Config.SOUNDS_DIR) # schedule next delay = rand_between(imin, imax) * 60 with auto_random_lock: if state.auto_random_active: auto_random_params['next_ts'] = time.time() + delay auto_random_timer = threading.Timer(delay, _auto_random_tick) auto_random_timer.daemon = True auto_random_timer.start() _persist_auto_state() def rand_between(a, b): return a + random.random() * (b - a) def _persist_auto_state(clear=False): try: if clear: if os.path.exists(AUTO_STATE_PATH): os.remove(AUTO_STATE_PATH) return data = { "active": state.auto_random_active, "next_ts": auto_random_params.get('next_ts'), "imin": auto_random_params.get('imin'), "imax": auto_random_params.get('imax'), "dmin": auto_random_params.get('dmin'), "dmax": auto_random_params.get('dmax'), "theme": auto_random_params.get('theme') } os.makedirs(os.path.dirname(AUTO_STATE_PATH), exist_ok=True) with open(AUTO_STATE_PATH, 'w', encoding='utf-8') as f: json.dump(data, f) except Exception as e: logger.error(f"Persist auto-random state fehlgeschlagen: {e}") def _prune_history(sound_id, now, window_seconds=DEFAULT_WINDOW_SECONDS): dq = random_history[sound_id] while dq and now - dq[0] > window_seconds: dq.popleft() return dq api_bp = Blueprint('api', __name__) @api_bp.route('/connect', methods=['POST']) def api_connect(): if state.current_config is None: return jsonify({"success": False, "message": "Keine Konfiguration geladen"}), 400 try: hub_id = int(state.current_config.get('hub_id', 0)) hub_type = state.current_config.get('hub_type', '6channel') # '4channel' oder '6channel' # Alte Verbindung sauber trennen if state.current_device is not None: try: state.current_device.Disconnect() except: pass if state.current_module is not None: # ggf. Stop vor Disconnect try: state.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']: state.current_module = mk.Module6_0() else: state.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(state.current_module, device_attr): raise ValueError(f"Hub-ID {hub_id} nicht unterstützt (max 0-2)") state.current_device = getattr(state.current_module, device_attr) # Verbinden state.current_device.Connect() state.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 pass @api_bp.route('/reconnect', methods=['POST']) def api_reconnect(): if state.current_config is None: return jsonify({"success": False, "message": "Keine Konfiguration geladen"}), 400 try: # Alte Verbindung sauber beenden, falls nötig if state.current_device is not None: try: state.current_device.Disconnect() except: pass # Neu verbinden (kopierter Code aus api_connect) hub_id = int(state.current_config.get('hub_id', 0)) hub_type = state.current_config.get('hub_type', '6channel') mk = MouldKing() mk.SetAdvertiser(advertiser) try: mk.SetTracer(tracer) except: pass if hub_type.lower() in ['6channel', '6']: state.current_module = mk.Module6_0() else: state.current_module = mk.Module4_0() device_attr = f'Device{hub_id}' if not hasattr(state.current_module, device_attr): raise ValueError(f"Hub-ID {hub_id} nicht unterstützt") state.current_device = getattr(state.current_module, device_attr) state.current_device.Connect() state.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 pass @api_bp.route('/control', methods=['POST']) def api_control(): if state.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 state.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! state.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 pass @api_bp.route('/stop_all', methods=['POST']) def api_stop_all(): if state.current_device is None: return jsonify({"success": False, "message": "Nicht verbunden"}), 400 try: # Nur stoppen – KEIN Disconnect! state.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 pass @api_bp.route('/status', methods=['GET']) def api_status(): if state.current_device is not None: return jsonify({ "connected": True, "message": "Verbunden" }) else: return jsonify({ "connected": False, "message": "Keine aktive Verbindung" }), 200 pass @api_bp.route('/play_sound', methods=['POST']) def api_play_sound(): try: data = request.get_json() sound_id = data.get('sound_id') channel_req = data.get('channel') # optional: spezifischer Channel loop_req = data.get('loop', 0) # optional: -1 oder true für Endlosschleife if not sound_id: return jsonify({"success": False, "message": "Kein sound_id angegeben"}), 400 # Sounds zusammenstellen: lokalspezifisch + globale Default-Sounds config_sounds = state.current_config.get('sounds', []) if state.current_config else [] global_sounds = load_default_sounds(current_app.config['CONFIG_DIR']) sb_sounds = [] if state.current_soundboard: for key in ('backgrounds', 'sounds', 'random_pool'): sb_sounds.extend(state.current_soundboard.get(key, [])) # IDs sicherstellen for s in sb_sounds: s.setdefault('id', s.get('file')) all_sounds = list(config_sounds) + list(global_sounds) + sb_sounds if not all_sounds: return jsonify({"success": False, "message": "Keine Sounds konfiguriert"}), 400 sound_entry = next((s for s in all_sounds if s.get('id') == sound_id), None) if not sound_entry: return jsonify({"success": False, "message": f"Sound mit ID '{sound_id}' nicht gefunden"}), 404 res = _play_sound_entry(sound_entry, channel_req, loop_req) if res is not None: return res sound_name = sound_entry.get('name') or sound_entry.get('id') or sound_entry.get('file', '?') logger.info(f"Spiele Sound: {sound_name} ({sound_id})") return jsonify({"success": True, "message": f"Spiele: {sound_name}"}) except Exception as e: logger.exception("Play-Sound-Fehler") return jsonify({"success": False, "message": str(e)}), 500 pass # ---------- Soundboard (themenbezogen, hub-unabhängig) ---------- @api_bp.route('/soundboard/configs', methods=['GET']) def api_soundboard_configs(): configs = load_soundboard_configs(current_app.config['SOUNDBOARD_CONFIG_DIR']) return jsonify(configs) @api_bp.route('/soundboard/load', methods=['POST']) def api_soundboard_load(): data = request.get_json() filename = data.get('filename') if not filename: return jsonify({"success": False, "message": "filename fehlt"}), 400 try: sb = load_soundboard_config(filename, current_app.config['SOUNDBOARD_CONFIG_DIR'], current_app.config['SOUNDS_DIR']) # Wenn das gleiche Theme erneut geladen wird, Auto-Random nicht stoppen same_theme = state.current_soundboard and state.current_soundboard.get('filename') == filename if not same_theme: _stop_auto_random_internal() state.current_soundboard = sb logger.info(f"Soundboard geladen: {filename}") return jsonify({"success": True, "soundboard": sb}) except Exception as e: logger.exception("Soundboard laden fehlgeschlagen") return jsonify({"success": False, "message": str(e)}), 500 @api_bp.route('/soundboard/load_default', methods=['POST']) def api_soundboard_load_default(): # Lädt das Standard-Theme, falls gesetzt und noch kein Soundboard aktiv ist try: if state.current_soundboard: return jsonify({"success": True, "soundboard": state.current_soundboard, "already_loaded": True}) default_file = read_default_theme(Config.DEFAULT_THEME_FILE) if not default_file: return jsonify({"success": False, "message": "Kein Standard-Theme gesetzt"}), 404 sb = load_soundboard_config(default_file, current_app.config['SOUNDBOARD_CONFIG_DIR'], current_app.config['SOUNDS_DIR']) state.current_soundboard = sb logger.info(f"Standard-Soundboard geladen: {default_file}") return jsonify({"success": True, "soundboard": sb, "default_loaded": True}) except Exception as e: logger.exception("Standard-Soundboard laden fehlgeschlagen") return jsonify({"success": False, "message": str(e)}), 500 @api_bp.route('/soundboard/play_random', methods=['POST']) def api_soundboard_play_random(): if state.current_soundboard is None: return jsonify({"success": False, "message": "Kein Soundboard geladen"}), 400 sound_entry, err = _pick_random_sound() if err: return jsonify({"success": False, "message": err}), 429 if "Limit" in err else 400 res = _play_sound_entry(sound_entry) if res is not None: return res # already Response return jsonify({"success": True, "message": f"Random: {sound_entry.get('name', sound_entry.get('id'))}"}) @api_bp.route('/soundboard/auto_start', methods=['POST']) def api_soundboard_auto_start(): if state.current_soundboard is None: return jsonify({"success": False, "message": "Kein Soundboard geladen"}), 400 data = request.get_json() or {} imin = float(data.get('interval_min', 5)) imax = float(data.get('interval_max', 10)) dmin = float(data.get('delay_min', 3)) dmax = float(data.get('delay_max', 12)) limit_ph = int(data.get('limit_per_hour', DEFAULT_MAX_PER_HOUR)) window_min = float(data.get('window_minutes', DEFAULT_WINDOW_SECONDS/60)) if imax < imin: imax = imin if dmax < dmin: dmax = dmin delay = rand_between(dmin, dmax) * 60 # erst laufenden Timer stoppen, bevor wir Lock nehmen _stop_auto_random_internal() with auto_random_lock: state.auto_random_active = True auto_random_params['imin'] = imin auto_random_params['imax'] = imax auto_random_params['dmin'] = dmin auto_random_params['dmax'] = dmax auto_random_params['next_ts'] = time.time() + delay auto_random_params['theme'] = state.current_soundboard.get('filename') auto_random_params['limit_per_hour'] = limit_ph auto_random_params['window_seconds'] = window_min * 60 global auto_random_timer auto_random_timer = threading.Timer(delay, _auto_random_tick) auto_random_timer.daemon = True auto_random_timer.start() _persist_auto_state() logger.info(f"Auto-Random gestartet (delay {delay/60:.1f} min, intervall {imin}-{imax} min)") return jsonify({"success": True, "message": "Auto-Random gestartet", "next_seconds": delay}) @api_bp.route('/soundboard/auto_stop', methods=['POST']) def api_soundboard_auto_stop(): stopped = _stop_auto_random_internal() return jsonify({"success": True, "message": "Auto-Random gestoppt", "was_running": stopped}) @api_bp.route('/soundboard/current', methods=['GET']) def api_soundboard_current(): if state.current_soundboard: return jsonify({"soundboard": state.current_soundboard}) return jsonify({"soundboard": None}), 200 @api_bp.route('/soundboard/status', methods=['GET']) def api_soundboard_status(): # Wiederherstellen, falls Prozess neu gestartet und State aus Datei verfügbar _restore_auto_state() with auto_random_lock: active = state.auto_random_active next_ts = auto_random_params.get('next_ts') imin = auto_random_params.get('imin') imax = auto_random_params.get('imax') dmin = auto_random_params.get('dmin') dmax = auto_random_params.get('dmax') current_theme = state.current_soundboard.get('filename') if state.current_soundboard else None limit_ph = auto_random_params.get('limit_per_hour', DEFAULT_MAX_PER_HOUR) window_sec = auto_random_params.get('window_seconds', DEFAULT_WINDOW_SECONDS) next_seconds = max(0, next_ts - time.time()) if next_ts else None return jsonify({ "active": active, "next_seconds": next_seconds, "interval_min": imin, "interval_max": imax, "delay_min": dmin, "delay_max": dmax, "current_theme": current_theme, "limit_per_hour": limit_ph, "window_minutes": window_sec/60 if window_sec else None }) def _stop_auto_random_internal(): global auto_random_timer stopped = False with auto_random_lock: if auto_random_timer: auto_random_timer.cancel() auto_random_timer = None stopped = True state.auto_random_active = False auto_random_params.clear() _persist_auto_state(clear=True) return stopped def _restore_auto_state(): try: if not os.path.exists(AUTO_STATE_PATH): return with open(AUTO_STATE_PATH, 'r', encoding='utf-8') as f: data = json.load(f) if not data.get('active'): return next_ts = data.get('next_ts') if not next_ts or next_ts < time.time(): return # abgelaufen theme = data.get('theme') # Theme laden, falls nicht gesetzt if theme and (state.current_soundboard is None or state.current_soundboard.get('filename') != theme): try: sb = load_soundboard_config(theme, current_app.config['SOUNDBOARD_CONFIG_DIR'], current_app.config['SOUNDS_DIR']) state.current_soundboard = sb except Exception: return with auto_random_lock: if state.auto_random_active: return state.auto_random_active = True auto_random_params.update({ 'imin': data.get('imin', 5), 'imax': data.get('imax', 10), 'dmin': data.get('dmin', 3), 'dmax': data.get('dmax', 12), 'next_ts': next_ts, 'theme': theme, 'limit_per_hour': data.get('limit_per_hour', DEFAULT_MAX_PER_HOUR), 'window_seconds': data.get('window_seconds', DEFAULT_WINDOW_SECONDS) }) delay = max(1, next_ts - time.time()) global auto_random_timer auto_random_timer = threading.Timer(delay, _auto_random_tick) auto_random_timer.daemon = True auto_random_timer.start() except Exception as e: logger.error(f"Restore auto-random state fehlgeschlagen: {e}") @api_bp.route('/stop_sound', methods=['POST']) def api_stop_sound(): try: data = request.get_json(silent=True) or {} channel_req = data.get('channel') with audio_lock: _ensure_mixer() if channel_req not in [None, ""]: try: ch_id = int(channel_req) pygame.mixer.Channel(ch_id).stop() except Exception as e: logger.error(f"Stop-Sound-Fehler (channel {channel_req}): {e}") return jsonify({"success": False, "message": "Ungültiger Channel"}), 400 else: # Alle Kanäle stoppen for ch_id in range(pygame.mixer.get_num_channels()): pygame.mixer.Channel(ch_id).stop() # Zur Sicherheit auch music stoppen pygame.mixer.music.stop() logger.info("Sound(s) gestoppt") return jsonify({"success": True}) except Exception as e: logger.error(f"Stop-Sound-Fehler: {e}") return jsonify({"success": False, "message": str(e)}), 500 pass @api_bp.route('/set_volume', methods=['POST']) def api_set_volume(): try: data = request.get_json() vol = float(data.get('volume', 0.8)) vol = max(0.0, min(1.0, vol)) channel_req = data.get('channel') with audio_lock: _ensure_mixer() if channel_req not in [None, ""]: ch_id = int(channel_req) pygame.mixer.Channel(ch_id).set_volume(vol) else: # global: alle Kanäle + music for ch_id in range(pygame.mixer.get_num_channels()): pygame.mixer.Channel(ch_id).set_volume(vol) pygame.mixer.music.set_volume(vol) logger.info(f"Volume auf {vol} gesetzt") return jsonify({"success": True}) except Exception as e: logger.error(f"Volume-Fehler: {e}") return jsonify({"success": False, "message": str(e)}), 500 pass