import socket import sys import os import json import time from pathlib import Path from typing import List, Optional from google import genai from google.genai import types from dotenv import load_dotenv load_dotenv() from config import * class TF5MixerController: def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT): self.host = host self.port = port self.socket = None self._ensure_cache_dir() self._cache = self._load_cache() def _connect(self): """Stabilisce la connessione se non già connesso.""" if self.socket is None: print('Inizializzazione socket...') try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(5) self.socket.connect((self.host, self.port)) except socket.error as e: self.socket = None raise ConnectionError(f"Impossibile connettersi al mixer: {e}") def _disconnect(self): """Chiude la connessione.""" if self.socket: print('Chiusura socket...') try: self.socket.close() print('Socket chiuso!') except: print('Errore durante chiusura socket!') pass finally: self.socket = None def _send_command(self, command: str) -> str: """Invia un comando al mixer e restituisce la risposta.""" max_retries = 2 for attempt in range(max_retries): try: print(f'Tentativo di connessione {attempt} per {command}') self._connect() self.socket.sendall((command + '\n').encode('utf-8')) response = self.socket.recv(4096) decoded = response.decode('utf-8', errors='ignore').strip() print(f'Risposta {decoded}') return decoded except socket.error as e: print(f'Errore di connessione dopo {max_retries} tentativi: {e}') self._disconnect() # Forza riconnessione al prossimo tentativo if attempt < max_retries - 1: time.sleep(0.1) continue else: return f"Errore di connessione dopo {max_retries} tentativi: {e}" def close(self): """Chiude la connessione (da chiamare alla fine).""" self._disconnect() def __enter__(self): """Context manager entry.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close() def _ensure_cache_dir(self): """Crea la directory di cache se non esiste.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) def _load_cache(self) -> dict: """Carica la cache dal file.""" if CACHE_FILE.exists(): try: with open(CACHE_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"⚠️ Errore nel caricamento della cache: {e}") return {"channels": {}, "mixes": {}, "timestamp": 0} def _save_cache(self): """Salva la cache nel file.""" try: with open(CACHE_FILE, 'w', encoding='utf-8') as f: json.dump(self._cache, f, indent=2, ensure_ascii=False) except Exception as e: print(f"⚠️ Errore nel salvataggio della cache: {e}") def _is_cache_valid(self) -> bool: """Verifica se la cache è ancora valida.""" if not self._cache.get("channels"): return False cache_age = time.time() - self._cache.get("timestamp", 0) return cache_age < CACHE_DURATION def _update_channel_cache(self, channel: int): """Aggiorna la cache per un singolo canale leggendo dal mixer. Args: channel: Numero del canale (1-40) """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return ch_idx = channel - 1 # Leggi nome resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") name = self._parse_name(resp_name) # Leggi stato ON/OFF resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") is_on = self._parse_value(resp_on) == "1" # Leggi livello resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None # Leggi pan resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") pan_raw = self._parse_value(resp_pan) try: pan_value = int(pan_raw) except: pan_value = None # Inizializza la struttura channels se non esiste if "channels" not in self._cache: self._cache["channels"] = {} # Aggiorna la cache self._cache["channels"][str(channel)] = { "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value } self._save_cache() def _update_mix_cache(self, mix_number: int): """Aggiorna la cache per un singolo mix/aux leggendo dal mixer. Args: mix_number: Numero del mix (1-20) """ if not 1 <= mix_number <= TF5_MIX_BUSSES: return mix_idx = mix_number - 1 # Leggi nome resp_name = self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0") name = self._parse_name(resp_name) # Leggi stato ON/OFF resp_on = self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0") is_on = self._parse_value(resp_on) == "1" # Leggi livello resp_level = self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None # Inizializza la struttura mixes se non esiste if "mixes" not in self._cache: self._cache["mixes"] = {} # Aggiorna la cache self._cache["mixes"][str(mix_number)] = { "mix": mix_number, "name": name, "on": is_on, "level_db": level_db } self._save_cache() def _parse_name(self, response: str) -> str: """Estrae il nome tra virgolette dalla risposta.""" try: start = response.find('"') + 1 end = response.rfind('"') if start > 0 and end > start: return response[start:end] return "Sconosciuto" except: return "Errore" def _parse_value(self, response: str) -> str: """Estrae l'ultimo valore da una risposta OK.""" parts = response.split() if len(parts) > 0 and parts[0] == "OK": return parts[-1] return "N/A" def refresh_cache(self) -> dict: """Aggiorna la cache leggendo tutti i canali e mix dal mixer. Returns: Un dizionario con lo stato dell'operazione """ print("🔄 Aggiornamento cache completo in corso...") channels_data = {} mixes_data = {} # Aggiorna canali for ch in range(1, TF5_INPUT_CHANNELS + 1): ch_idx = ch - 1 # Leggi nome resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") name = self._parse_name(resp_name) # Leggi stato ON/OFF resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") is_on = self._parse_value(resp_on) == "1" # Leggi livello resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None # Leggi pan resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") pan_raw = self._parse_value(resp_pan) try: pan_value = int(pan_raw) except: pan_value = None channels_data[str(ch)] = { "channel": ch, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value } time.sleep(0.05) # Aggiorna mix for mix in range(1, TF5_MIX_BUSSES + 1): mix_idx = mix - 1 # Leggi nome resp_name = self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0") name = self._parse_name(resp_name) # Leggi stato ON/OFF resp_on = self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0") is_on = self._parse_value(resp_on) == "1" # Leggi livello resp_level = self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None mixes_data[str(mix)] = { "mix": mix, "name": name, "on": is_on, "level_db": level_db } time.sleep(0.05) self._cache = { "channels": channels_data, "mixes": mixes_data, "timestamp": time.time() } self._save_cache() print(f"✅ Cache aggiornata con {len(channels_data)} canali e {len(mixes_data)} mix") return { "status": "success", "message": f"Cache aggiornata con {len(channels_data)} canali e {len(mixes_data)} mix", "channels_count": len(channels_data), "mixes_count": len(mixes_data) } def recall_scene(self, bank: str, scene_number: int) -> dict: """Richiama una scena dal banco A o B. Args: bank: Il banco della scena ('a' o 'b') scene_number: Il numero della scena (0-99) Returns: Un dizionario con lo stato dell'operazione """ if bank.lower() not in ['a', 'b']: return {"status": "error", "message": "Il banco deve essere 'a' o 'b'"} if not 0 <= scene_number <= 99: return {"status": "error", "message": "Il numero scena deve essere tra 0 e 99"} command = f"ssrecall_ex scene_{bank.lower()} {scene_number}" response = self._send_command(command) # Invalida la cache dopo il cambio scena self._cache["timestamp"] = 0 return { "status": "success" if "OK" in response else "error", "message": f"Scena {bank.upper()}{scene_number} richiamata. Cache invalidata.", "response": response } def set_channel_level(self, channel: int, level_db: float) -> dict: """Imposta il livello del fader di un canale in dB. Args: channel: Numero del canale (1-40) level_db: Livello in dB (da -inf a +10.0) Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} # Converti dB in valore interno (moltiplicato per 100) if level_db <= -138: internal_value = -32768 # -inf else: internal_value = int(level_db * 100) command = f"set MIXER:Current/InCh/Fader/Level {channel-1} 0 {internal_value}" response = self._send_command(command) # Aggiorna cache dal mixer per avere dati precisi if "OK" in response: self._update_channel_cache(channel) return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} impostato a {level_db:+.1f} dB", "response": response } def set_channel_on_off(self, channel: int, state: bool) -> dict: """Accende o spegne un canale. Args: channel: Numero del canale (1-40) state: True per accendere, False per spegnere Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} value = 1 if state else 0 command = f"set MIXER:Current/InCh/Fader/On {channel-1} 0 {value}" response = self._send_command(command) # Aggiorna cache dal mixer per avere dati precisi if "OK" in response: self._update_channel_cache(channel) return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} {'acceso' if state else 'spento'}", "response": response } def set_channel_pan(self, channel: int, pan_value: int) -> dict: """Imposta il pan di un canale. Args: channel: Numero del canale (1-40) pan_value: Valore pan da -63 (sinistra) a +63 (destra), 0 è centro Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} if not -63 <= pan_value <= 63: return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} command = f"set MIXER:Current/InCh/ToSt/Pan {channel-1} 0 {pan_value}" response = self._send_command(command) # Aggiorna cache dal mixer per avere dati precisi if "OK" in response: self._update_channel_cache(channel) pan_desc = "centro" if pan_value < 0: pan_desc = f"sinistra {abs(pan_value)}" elif pan_value > 0: pan_desc = f"destra {pan_value}" return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} pan impostato a {pan_desc}", "response": response } def set_mix_level(self, mix_number: int, level_db: float) -> dict: """Imposta il livello di un mix/aux. Args: mix_number: Numero del mix (1-20) level_db: Livello in dB (da -inf a +10.0) Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} if level_db <= -138: internal_value = -32768 else: internal_value = int(level_db * 100) command = f"set MIXER:Current/Mix/Fader/Level {mix_number-1} 0 {internal_value}" response = self._send_command(command) # Aggiorna cache dal mixer per avere dati precisi if "OK" in response: self._update_mix_cache(mix_number) return { "status": "success" if "OK" in response else "error", "message": f"Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response } def set_mix_on_off(self, mix_number: int, state: bool) -> dict: """Accende o spegne un mix/aux. Args: mix_number: Numero del mix (1-20) state: True per accendere, False per spegnere Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} value = 1 if state else 0 command = f"set MIXER:Current/Mix/Fader/On {mix_number-1} 0 {value}" response = self._send_command(command) # Aggiorna cache dal mixer per avere dati precisi if "OK" in response: self._update_mix_cache(mix_number) return { "status": "success" if "OK" in response else "error", "message": f"Mix {mix_number} {'acceso' if state else 'spento'}", "response": response } def mute_multiple_channels(self, channels: List[int]) -> dict: """Muta più canali contemporaneamente. Args: channels: Lista di numeri di canale da mutare (es: [1, 2, 5, 8]) Returns: Un dizionario con lo stato dell'operazione """ results = [] for ch in channels: result = self.set_channel_on_off(ch, False) results.append(result) success_count = sum(1 for r in results if r["status"] == "success") return { "status": "success" if success_count == len(channels) else "partial", "message": f"Mutati {success_count}/{len(channels)} canali: {channels}", "details": results } def unmute_multiple_channels(self, channels: List[int]) -> dict: """Riattiva più canali contemporaneamente. Args: channels: Lista di numeri di canale da riattivare (es: [1, 2, 5, 8]) Returns: Un dizionario con lo stato dell'operazione """ results = [] for ch in channels: result = self.set_channel_on_off(ch, True) results.append(result) success_count = sum(1 for r in results if r["status"] == "success") return { "status": "success" if success_count == len(channels) else "partial", "message": f"Riattivati {success_count}/{len(channels)} canali: {channels}", "details": results } def get_channel_info(self, channel: int, force_refresh: bool = False) -> dict: """Legge le informazioni di un canale (nome, livello, stato, pan). Usa la cache se disponibile e valida. Args: channel: Numero del canale (1-40) force_refresh: Se True, ignora la cache e legge dal mixer Returns: Un dizionario con tutte le informazioni del canale """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} # Usa cache se valida e non forzato il refresh if not force_refresh and self._is_cache_valid(): cached_data = self._cache.get("channels", {}).get(str(channel)) if cached_data: return { "status": "success", "source": "cache", **cached_data } # Altrimenti leggi dal mixer ch_idx = channel - 1 resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") name = self._parse_name(resp_name) resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") is_on = self._parse_value(resp_on) == "1" resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") pan_raw = self._parse_value(resp_pan) try: pan_value = int(pan_raw) except: pan_value = None return { "status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value } def get_mix_info(self, mix_number: int, force_refresh: bool = False) -> dict: """Legge le informazioni di un mix/aux (nome, livello, stato). Usa la cache se disponibile e valida. Args: mix_number: Numero del mix (1-20) force_refresh: Se True, ignora la cache e legge dal mixer Returns: Un dizionario con tutte le informazioni del mix """ if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} # Usa cache se valida e non forzato il refresh if not force_refresh and self._is_cache_valid(): cached_data = self._cache.get("mixes", {}).get(str(mix_number)) if cached_data: return { "status": "success", "source": "cache", **cached_data } # Altrimenti leggi dal mixer mix_idx = mix_number - 1 resp_name = self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0") name = self._parse_name(resp_name) resp_on = self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0") is_on = self._parse_value(resp_on) == "1" resp_level = self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None return { "status": "success", "source": "mixer", "mix": mix_number, "name": name, "on": is_on, "level_db": level_db } def search_channels_by_name(self, search_term: str) -> dict: """Cerca canali il cui nome contiene un determinato termine. Usa la cache per velocizzare la ricerca. Args: search_term: Il termine da cercare nei nomi dei canali (case-insensitive) Returns: Un dizionario con la lista dei canali trovati """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() found_channels = [] for ch_str, info in self._cache.get("channels", {}).items(): if search_lower in info.get("name", "").lower(): found_channels.append({ "channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": info["level_db"] }) # Ordina per numero canale found_channels.sort(key=lambda x: x["channel"]) return { "status": "success", "search_term": search_term, "found_count": len(found_channels), "channels": found_channels, "message": f"Trovati {len(found_channels)} canali contenenti '{search_term}'" } def search_mixes_by_name(self, search_term: str) -> dict: """Cerca mix/aux il cui nome contiene un determinato termine. Usa la cache per velocizzare la ricerca. Args: search_term: Il termine da cercare nei nomi dei mix (case-insensitive) Returns: Un dizionario con la lista dei mix trovati """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() found_mixes = [] for mix_str, info in self._cache.get("mixes", {}).items(): if search_lower in info.get("name", "").lower(): found_mixes.append({ "mix": info["mix"], "name": info["name"], "on": info["on"], "level_db": info["level_db"] }) # Ordina per numero mix found_mixes.sort(key=lambda x: x["mix"]) return { "status": "success", "search_term": search_term, "found_count": len(found_mixes), "mixes": found_mixes, "message": f"Trovati {len(found_mixes)} mix contenenti '{search_term}'" } def get_all_channels_summary(self) -> dict: """Ottiene un riepilogo di tutti i canali con nome e stato. Usa la cache per velocizzare. Returns: Un dizionario con il riepilogo di tutti i canali """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() channels = [] for ch_str, info in self._cache.get("channels", {}).items(): channels.append({ "channel": info["channel"], "name": info["name"], "on": info["on"] }) # Ordina per numero canale channels.sort(key=lambda x: x["channel"]) cache_age = time.time() - self._cache.get("timestamp", 0) return { "status": "success", "total_channels": len(channels), "channels": channels, "cache_age_seconds": int(cache_age), "message": f"Riepilogo di {len(channels)} canali (cache: {int(cache_age)}s fa)" } def get_all_mixes_summary(self) -> dict: """Ottiene un riepilogo di tutti i mix/aux con nome e stato. Usa la cache per velocizzare. Returns: Un dizionario con il riepilogo di tutti i mix """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() mixes = [] for mix_str, info in self._cache.get("mixes", {}).items(): mixes.append({ "mix": info["mix"], "name": info["name"], "on": info["on"] }) # Ordina per numero mix mixes.sort(key=lambda x: x["mix"]) cache_age = time.time() - self._cache.get("timestamp", 0) return { "status": "success", "total_mixes": len(mixes), "mixes": mixes, "cache_age_seconds": int(cache_age), "message": f"Riepilogo di {len(mixes)} mix (cache: {int(cache_age)}s fa)" }