From 3f09ff2bd647611bc4b75f0398a4992442be5a16 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 10 Nov 2025 19:56:47 +0100 Subject: [PATCH] fix sync cache between bot and mixer --- .tf5_mixer_cache/channels_cache.json | 2 +- __pycache__/config.cpython-312.pyc | Bin 458 -> 458 bytes __pycache__/mixer_controller.cpython-312.pyc | Bin 40211 -> 43777 bytes cache/mixer_state.json | 407 ++++++ config.py | 2 +- conversations/309700430.json | 93 ++ conversations/436176546.json | 21 + conversations/4762864.json | 130 +- mixer_controller.py | 1182 ++++++------------ profiles/1552084582.json | 17 + profiles/309700430.json | 13 + profiles/392578198.json | 17 + profiles/436176546.json | 17 + profiles/4762864.json | 14 +- profiles/540754896.json | 17 + 15 files changed, 1048 insertions(+), 884 deletions(-) create mode 100644 cache/mixer_state.json create mode 100644 conversations/309700430.json create mode 100644 conversations/436176546.json create mode 100644 profiles/1552084582.json create mode 100644 profiles/309700430.json create mode 100644 profiles/392578198.json create mode 100644 profiles/436176546.json create mode 100644 profiles/540754896.json diff --git a/.tf5_mixer_cache/channels_cache.json b/.tf5_mixer_cache/channels_cache.json index 803ccc3..2a443d7 100644 --- a/.tf5_mixer_cache/channels_cache.json +++ b/.tf5_mixer_cache/channels_cache.json @@ -403,5 +403,5 @@ "level_db": 0.01 } }, - "timestamp": 1762198236.5701377 + "timestamp": 1762800542.9757586 } \ No newline at end of file diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index 674be36b5410dd87dd065f1b410bf3069737fef0..3ccb7fedaefb94fa741048e113a3fab5e48412af 100644 GIT binary patch delta 26 gcmX@be2SUpG%qg~0}!z43uR8^$!0X!SP{es08$bK{r~^~ delta 26 gcmX@be2SUpG%qg~0}wns{6AwNPd1~`#)=?D0B%VLSO5S3 diff --git a/__pycache__/mixer_controller.cpython-312.pyc b/__pycache__/mixer_controller.cpython-312.pyc index de7bc6a95bbaa14b4c922b8a95b8abef4171f405..866673a3f81cd68fe5c84ff2aec653a9e9ae95e8 100644 GIT binary patch literal 43777 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 literal 40211 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 0): - return 10.0 # Massimo pratico - elif isinstance(value, float) and math.isnan(value): - return 0.0 # Default a 0 se NaN + self._command_lock = threading.Lock() + self._response_queue = queue.Queue(maxsize=1) + self._reader_thread = None + self._is_running = threading.Event() + self._is_connected = threading.Event() - return value + self.start() - def _connect(self): - """Stabilisce la connessione se non già connesso.""" - if self.socket is None: - print('Inizializzazione socket...') + def start(self): + if self._is_running.is_set(): + print("Controller già in esecuzione.") + return + + print("🚀 Avvio del Mixer Controller...") + self._is_running.set() + self._reader_thread = threading.Thread(target=self._connection_manager) + self._reader_thread.daemon = True + self._reader_thread.start() + + if not self._is_connected.wait(timeout=5): + print("⚠️ Impossibile connettersi al mixer all'avvio.") + else: + print("✅ Controller avviato e connesso.") + + def _connection_manager(self): + while self._is_running.is_set(): try: + print(f"🔌 Tentativo di connessione a {self.host}:{self.port}...") self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.settimeout(5) + self.socket.settimeout(10) self.socket.connect((self.host, self.port)) - except socket.error as e: - self.socket = None - raise ConnectionError(f"Impossibile connettersi al mixer: {e}") + self.socket.settimeout(1) + self._is_connected.set() + print(f"🔗 Connesso a {self.host}:{self.port}") - 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 + with self._cache_lock: + is_cache_empty = not self._cache.get("channels") + + if is_cache_empty: + print("Cache iniziale vuota. Avvio refresh completo...") + self.refresh_cache() + + self._socket_reader() + except socket.error as e: + print(f"🔥 Errore di connessione: {e}. Riprovo tra 5 secondi...") finally: + self._is_connected.clear() + if self.socket: + self.socket.close() self.socket = None + if self._is_running.is_set(): + time.sleep(5) + print("🛑 Gestore connessioni terminato.") - 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): + def _socket_reader(self): + buffer = "" + while self._is_running.is_set(): 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 + data = self.socket.recv(4096) + if not data: + print("💔 Connessione chiusa dal mixer.") + break + buffer += data.decode('utf-8', errors='ignore') + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue + + if line.startswith("NOTIFY"): + self._handle_notify(line) + elif line.startswith("OK") or line.startswith("ERROR"): + try: + self._response_queue.put_nowait(line) + except queue.Full: + print(f"⚠️ Coda risposte piena, scartato: {line}") + else: + print(f"🤔 Messaggio non gestito: {line}") + + except socket.timeout: + continue except socket.error as e: - print(f'Errore di connessione dopo {max_retries} tentativi: {e}') - self._disconnect() + print(f"🔥 Errore di lettura dal socket: {e}") + break - if attempt < max_retries - 1: - time.sleep(0.1) - continue + def _handle_notify(self, message: str): + """ + Analizza un messaggio NOTIFY e aggiorna la cache in modo robusto. + Versione che gestisce il formato esatto 'NOTIFY set ...' con parametri extra. + """ + print(f"RECV NOTIFY: {message}") + + parts = message.split() + if len(parts) < 2: return + + path = None + path_index = -1 + for i, part in enumerate(parts): + if part.startswith("MIXER:"): + path = part + path_index = i + break + + if path is None: + print(f" -> ATTENZIONE: Nessun path valido (che inizia con 'MIXER:') trovato nel messaggio.") + return + + print(f" -> Tentativo di acquisire il lock per l'aggiornamento (Path: {path})...") + with self._cache_lock: + print(f" -> LOCK ACQUISITO. Elaborazione...") + try: + updated = False + + # --- LOGICA DI PARSING SPECIFICA E ROBUSTA --- + # Per 'set' commands, i parametri sono: ch_idx, 0, value, [optional_string] + # Indice ch: path_index + 1 + # Indice valore: path_index + 3 + + # Gestione NOME Canale (usa un parsing diverso) + if "InCh/Label/Name" in path: + params = parts[path_index + 1:] + if len(params) >= 2: + ch_idx, name = int(params[0]), " ".join(params[1:]).strip('"') + ch_key = str(ch_idx + 1) + + channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) + channel_data["name"] = name + print(f" -> SUCCESS: Ch {ch_key} nome -> '{name}'") + updated = True + + # Gestione ON/OFF Canale + elif "InCh/Fader/On" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + state_val = parts[path_index + 3] + state = state_val == "1" + ch_key = str(ch_idx + 1) + + channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) + channel_data["on"] = state + print(f" -> SUCCESS: Ch {ch_key} stato -> {'ON' if state else 'OFF'}") + updated = True + + # Gestione LIVELLO Canale + elif "InCh/Fader/Level" in path: + if len(parts) > path_index + 3: + ch_idx = int(parts[path_index + 1]) + level_int_str = parts[path_index + 3] + level_int = int(level_int_str) + ch_key = str(ch_idx + 1) + level_db = level_int / 100.0 if level_int > -32768 else float('-inf') + + channel_data = self._cache.setdefault("channels", {}).setdefault(ch_key, {"channel": int(ch_key)}) + channel_data["level_db"] = level_db + print(f" -> SUCCESS: Ch {ch_key} level -> {level_db:.2f} dB") + updated = True + + if updated: + self._cache['timestamp'] = time.time() else: - return f"Errore di connessione dopo {max_retries} tentativi: {e}" + print(f" -> ATTENZIONE: Nessuna condizione di aggiornamento trovata per il path '{path}'") + + except (ValueError, IndexError) as e: + print(f" -> ERRORE CRITICO durante l'elaborazione: {e}") + finally: + print(f" -> LOCK RILASCIATO.") + + def _send_command(self, command: str) -> str: + """Invia un comando al mixer e attende la risposta.""" + if not self._is_connected.wait(timeout=5): + return "ERROR connection - Mixer non connesso." + + with self._command_lock: + try: + while not self._response_queue.empty(): + self._response_queue.get_nowait() + + print(f"SEND: {command}") + self.socket.sendall((command + '\n').encode('utf-8')) + + response = self._response_queue.get(timeout=5) + print(f"RECV RESP: {response}") + return response + + except queue.Empty: + return "ERROR timeout - Nessuna risposta dal mixer." + except socket.error as e: + self._is_connected.clear() + return f"ERROR connection - Errore socket: {e}" def close(self): - """Chiude la connessione (da chiamare alla fine).""" - self._disconnect() + """Ferma il controller e chiude la connessione.""" + if not self._is_running.is_set(): + return + print("🛑 Chiusura del Mixer Controller...") + self._is_running.clear() + if self.socket: + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + except socket.error: + pass + if self._reader_thread and self._reader_thread.is_alive(): + self._reader_thread.join(timeout=2) + print("✅ Controller fermato.") + self._save_cache() def __enter__(self): - """Context manager entry.""" return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" self.close() + # --- Metodi di gestione della cache e di utilità (invariati) --- + def _sanitize_value(self, value): + if value is None: return -120.0 + if value == float('-inf') or (isinstance(value, float) and math.isinf(value) and value < 0): return -120.0 + elif value == float('inf') or (isinstance(value, float) and math.isinf(value) and value > 0): return 10.0 + elif isinstance(value, float) and math.isnan(value): return 0.0 + return value + 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} + with self._cache_lock: + if CACHE_FILE.exists(): + try: + with open(CACHE_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + # Assicura che le chiavi principali esistano + if "channels" not in data: data["channels"] = {} + if "mixes" not in data: data["mixes"] = {} + return data + 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, convertendo -inf in null.""" - try: - # Crea una copia della cache per la serializzazione - cache_to_save = self._prepare_cache_for_json(self._cache) - - with open(CACHE_FILE, 'w', encoding='utf-8') as f: - json.dump(cache_to_save, f, indent=2, ensure_ascii=False) - except Exception as e: - print(f"⚠️ Errore nel salvataggio della cache: {e}") + with self._cache_lock: + try: + cache_to_save = self._prepare_cache_for_json(self._cache) + with open(CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(cache_to_save, f, indent=2, ensure_ascii=False) + except Exception as e: + print(f"⚠️ Errore nel salvataggio della cache: {e}") def _prepare_cache_for_json(self, obj): - """Converte ricorsivamente -inf in null per la serializzazione JSON.""" - if isinstance(obj, dict): - return {k: self._prepare_cache_for_json(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self._prepare_cache_for_json(item) for item in obj] + if isinstance(obj, dict): return {k: self._prepare_cache_for_json(v) for k, v in obj.items()} + elif isinstance(obj, list): return [self._prepare_cache_for_json(item) for item in obj] elif isinstance(obj, float): - # Converti -inf in null per JSON - if obj == float('-inf'): - return None - return obj - else: + if obj == float('-inf'): return None return obj + else: return obj 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 + with self._cache_lock: + if not self._cache.get("channels"): return False + cache_age = time.time() - self._cache.get("timestamp", 0) + return cache_age < CACHE_DURATION def _normalize_level_from_cache(self, level_value): - """Normalizza il valore del livello dalla cache (gestisce None come -inf).""" - if level_value is None: - return float('-inf') + if level_value is None: return float('-inf') return level_value - - 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 prima riga della risposta.""" try: - # Considera solo la prima riga per evitare di includere notifiche successive first_line = response.split('\n')[0] start = first_line.find('"') + 1 end = first_line.rfind('"') - if start > 0 and end > start: - return first_line[start:end] + if start > 0 and end > start: return first_line[start:end] return "Sconosciuto" - except: - return "Errore" + except: return "Errore" def _parse_value(self, response: str) -> str: - """Estrae l'ultimo valore dalla prima riga di una risposta OK.""" - # Considera solo la prima riga per evitare notifiche successive first_line = response.split('\n')[0] parts = first_line.split() - if len(parts) > 0 and parts[0] == "OK": - return parts[-1] + if len(parts) > 0 and parts[0] == "OK": return parts[-1] return "N/A" - + + # --- API Pubblica del Controller (invariata) --- + # ... tutti gli altri metodi da refresh_cache in poi rimangono identici ... 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 + try: level_db = int(level_raw) / 100.0 if int(level_raw) > -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 + 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.01) # Riduci il delay, la connessione persistente è veloce - 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 + try: level_db = int(level_raw) / 100.0 if int(level_raw) > -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.01) - 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() - } + with self._cache_lock: + self._cache = {"channels": channels_data, "mixes": mixes_data, "timestamp": time.time()} self._save_cache() + + msg = f"Cache aggiornata con {len(channels_data)} canali e {len(mixes_data)} mix" + print(f"✅ {msg}") + return {"status": "success", "message": msg, "channels_count": len(channels_data), "mixes_count": len(mixes_data)} - 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) - } - + # ... TUTTI GLI ALTRI METODI PUBBLICI RESTANO UGUALI ... 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"} + 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 + if "OK" in response: + print("Scene richiamata. La cache si aggiornerà tramite NOTIFY. Schedulato un refresh completo in 5s per sicurezza.") + threading.Timer(5.0, self.refresh_cache).start() - return { - "status": "success" if "OK" in response else "error", - "message": f"Scena {bank.upper()}{scene_number} richiamata. Cache invalidata.", - "response": response - } + return {"status": "success" if "OK" in response else "error", "message": f"Scena {bank.upper()}{scene_number} richiamata.", "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) - + if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + internal_value = -32768 if level_db <= -138 else 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 - } + 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}" + if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + command = f"set MIXER:Current/InCh/Fader/On {channel-1} 0 {1 if state else 0}" 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 - } + 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"} - + 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 - } + 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) - + if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + internal_value = -32768 if level_db <= -138 else 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 - } + 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}" + if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/Mix/Fader/On {mix_number-1} 0 {1 if state else 0}" 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 - } + 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) - + results = [self.set_channel_on_off(ch, False) for ch in channels] 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 - } + 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) - + results = [self.set_channel_on_off(ch, True) for ch in channels] 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 - } + 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 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} if not force_refresh and self._is_cache_valid(): - cached_data = self._cache.get("channels", {}).get(str(channel)) + with self._cache_lock: + cached_data = self._cache.get("channels", {}).get(str(channel)) if cached_data: - # Normalizza level_db (None -> -inf) - level_db = self._normalize_level_from_cache(cached_data.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - return { - "status": "success", - "source": "cache", - "channel": cached_data["channel"], - "name": cached_data["name"], - "on": cached_data["on"], - "level_db": sanitized_level_db, - "pan": cached_data.get("pan") - } - - # Altrimenti leggi dal mixer + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db"))) + return {"status": "success", "source": "cache", "channel": cached_data["channel"], "name": cached_data["name"], "on": cached_data["on"], "level_db": sanitized_level_db, "pan": cached_data.get("pan")} + 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 - + try: level_db = int(level_raw) / 100.0 if int(level_raw) > -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 - - sanitized_level_db = self._sanitize_value(level_db) - return { - "status": "success", - "source": "mixer", - "channel": channel, - "name": name, - "on": is_on, - "level_db": sanitized_level_db, - "pan": pan_value - } + try: pan_value = int(pan_raw) + except: pan_value = None + + with self._cache_lock: + if "channels" not in self._cache: self._cache["channels"] = {} + self._cache["channels"][str(channel)] = {"channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} + self._cache['timestamp'] = time.time() + + return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, "level_db": self._sanitize_value(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 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} if not force_refresh and self._is_cache_valid(): - cached_data = self._cache.get("mixes", {}).get(str(mix_number)) + with self._cache_lock: + cached_data = self._cache.get("mixes", {}).get(str(mix_number)) if cached_data: - # Normalizza level_db (None -> -inf) - level_db = self._normalize_level_from_cache(cached_data.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - return { - "status": "success", - "source": "cache", - "mix": cached_data["mix"], - "name": cached_data["name"], - "on": cached_data["on"], - "level_db": sanitized_level_db - } - - # Altrimenti leggi dal mixer + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db"))) + return {"status": "success", "source": "cache", "mix": cached_data["mix"], "name": cached_data["name"], "on": cached_data["on"], "level_db": sanitized_level_db} + 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 + try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') + except: level_db = None + + with self._cache_lock: + if "mixes" not in self._cache: self._cache["mixes"] = {} + self._cache["mixes"][str(mix_number)] = {"mix": mix_number, "name": name, "on": is_on, "level_db": level_db} + self._cache['timestamp'] = time.time() - sanitized_level_db = self._sanitize_value(level_db) - return { - "status": "success", - "source": "mixer", - "mix": mix_number, - "name": name, - "on": is_on, - "level_db": sanitized_level_db - } + return {"status": "success", "source": "mixer", "mix": mix_number, "name": name, "on": is_on, "level_db": self._sanitize_value(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() - + 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(): + with self._cache_lock: + channels_copy = list(self._cache.get("channels", {}).values()) + for info in channels_copy: if search_lower in info.get("name", "").lower(): - # Normalizza level_db quando restituisci i risultati - level_db = self._normalize_level_from_cache(info.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - found_channels.append({ - "channel": info["channel"], - "name": info["name"], - "on": info["on"], - "level_db": sanitized_level_db - }) - - # Ordina per numero canale + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + found_channels.append({"channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) 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}'" - } + 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() - + 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(): + with self._cache_lock: + mixes_copy = list(self._cache.get("mixes", {}).values()) + for info in mixes_copy: if search_lower in info.get("name", "").lower(): - # Normalizza level_db quando restituisci i risultati - level_db = self._normalize_level_from_cache(info.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - found_mixes.append({ - "mix": info["mix"], - "name": info["name"], - "on": info["on"], - "level_db": sanitized_level_db - }) - - # Ordina per numero mix + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + found_mixes.append({"mix": info["mix"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) 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}'" - } + 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() - + if not self._is_cache_valid(): self.refresh_cache() channels = [] - for ch_str, info in self._cache.get("channels", {}).items(): - level_db = self._normalize_level_from_cache(info.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - channels.append({ - "channel": info["channel"], - "name": info["name"], - "on": info["on"], - "level_db": sanitized_level_db - }) - - # Ordina per numero canale + with self._cache_lock: + channels_copy = list(self._cache.get("channels", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + for info in channels_copy: + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + channels.append({"channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) 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)" - } + 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() - + if not self._is_cache_valid(): self.refresh_cache() mixes = [] - for mix_str, info in self._cache.get("mixes", {}).items(): - level_db = self._normalize_level_from_cache(info.get("level_db")) - sanitized_level_db = self._sanitize_value(level_db) - mixes.append({ - "mix": info["mix"], - "name": info["name"], - "on": info["on"], - "level_db": sanitized_level_db - }) - - # Ordina per numero mix + with self._cache_lock: + mixes_copy = list(self._cache.get("mixes", {}).values()) + cache_age = time.time() - self._cache.get("timestamp", 0) + for info in mixes_copy: + sanitized_level_db = self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + mixes.append({"mix": info["mix"], "name": info["name"], "on": info["on"], "level_db": sanitized_level_db}) 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)" - } + 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)"} def set_channel_to_mix_level(self, channel: int, mix_number: int, level_db: float) -> dict: - """Imposta il livello di invio di un canale verso un mix/aux. - - Args: - channel: Numero del canale (1-40) - 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 <= channel <= TF5_INPUT_CHANNELS: - return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - 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) - + if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + internal_value = -32768 if level_db <= -138 else int(level_db * 100) command = f"set MIXER:Current/InCh/ToMix/Level {channel-1} {mix_number-1} {internal_value}" response = self._send_command(command) - - return { - "status": "success" if "OK" in response else "error", - "message": f"Canale {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", - "response": response - } + return {"status": "success" if "OK" in response else "error", "message": f"Canale {channel} → Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response} def set_channel_to_mix_on_off(self, channel: int, mix_number: int, state: bool) -> dict: - """Accende o spegne l'invio di un canale verso un mix/aux. - - Args: - channel: Numero del canale (1-40) - 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 <= channel <= TF5_INPUT_CHANNELS: - return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - 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/InCh/ToMix/On {channel-1} {mix_number-1} {value}" + if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + command = f"set MIXER:Current/InCh/ToMix/On {channel-1} {mix_number-1} {1 if state else 0}" response = self._send_command(command) - - return { - "status": "success" if "OK" in response else "error", - "message": f"Invio canale {channel} → Mix {mix_number} {'acceso' if state else 'spento'}", - "response": response - } + return {"status": "success" if "OK" in response else "error", "message": f"Invio canale {channel} → Mix {mix_number} {'acceso' if state else 'spento'}", "response": response} def get_channel_to_mix_info(self, channel: int, mix_number: int) -> dict: - """Legge le informazioni dell'invio di un canale verso un mix. - - Args: - channel: Numero del canale (1-40) - mix_number: Numero del mix (1-20) - - Returns: - Un dizionario con le informazioni del send - """ - if not 1 <= channel <= TF5_INPUT_CHANNELS: - return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} - if not 1 <= mix_number <= TF5_MIX_BUSSES: - return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - - ch_idx = channel - 1 - mix_idx = mix_number - 1 - - # Leggi livello + if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} + if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} + ch_idx, mix_idx = channel - 1, mix_number - 1 resp_level = self._send_command(f"get MIXER:Current/InCh/ToMix/Level {ch_idx} {mix_idx}") 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 stato ON/OFF + try: level_db = int(level_raw) / 100.0 if int(level_raw) > -32768 else float('-inf') + except: level_db = None resp_on = self._send_command(f"get MIXER:Current/InCh/ToMix/On {ch_idx} {mix_idx}") is_on = self._parse_value(resp_on) == "1" - sanitized_level_db = self._sanitize_value(level_db) - - return { - "status": "success", - "channel": channel, - "mix": mix_number, - "on": is_on, - "send_level_db": sanitized_level_db, - "message": f"Canale {channel} → Mix {mix_number}: {sanitized_level_db:+.1f} dB ({'ON' if is_on else 'OFF'})" - } + return {"status": "success", "channel": channel, "mix": mix_number, "on": is_on, "send_level_db": sanitized_level_db, "message": f"Canale {channel} → Mix {mix_number}: {sanitized_level_db:+.1f} dB ({'ON' if is_on else 'OFF'})"} def get_full_mix_details(self, mix_number: int) -> dict: - """ - Raccoglie informazioni complete su un mix, inclusi tutti i canali inviati ad esso. - Questa funzione è fondamentale per l'analisi del mix. - - Args: - mix_number: Numero del mix (1-20) - - Returns: - Un dizionario con i dettagli del mix e una lista dei canali inviati. - """ - if not 1 <= mix_number <= TF5_MIX_BUSSES: - return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} - - # Aggiorna la cache se non è valida per avere i nomi dei canali - if not self._is_cache_valid(): - self.refresh_cache() - - # Ottieni le info principali 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}"} + if not self._is_cache_valid(): self.refresh_cache() mix_info = self.get_mix_info(mix_number) - if mix_info["status"] != "success": - return mix_info - + if mix_info["status"] != "success": return mix_info sends = [] - # Itera su tutti i canali di input per vedere cosa mandano a questo mix for channel in range(1, TF5_INPUT_CHANNELS + 1): send_info = self.get_channel_to_mix_info(channel, mix_number) - - # Aggiungi solo i canali che sono effettivamente inviati al mix if send_info["status"] == "success" and send_info["send_level_db"] > -120.0: - # Recupera le info principali del canale dalla cache per efficienza - channel_cache = self._cache.get("channels", {}).get(str(channel), {}) - - sends.append({ - "channel": channel, - "channel_name": channel_cache.get("name", "Sconosciuto"), - "channel_is_on": channel_cache.get("on", False), - "send_level_db": send_info["send_level_db"], - "send_is_on": send_info["on"] - }) - - # Ordina i canali dal più forte al più debole per facilitare l'analisi + with self._cache_lock: + channel_cache = self._cache.get("channels", {}).get(str(channel), {}) + sends.append({"channel": channel, "channel_name": channel_cache.get("name", "Sconosciuto"), "channel_is_on": channel_cache.get("on", False), "send_level_db": send_info["send_level_db"], "send_is_on": send_info["on"]}) sends.sort(key=lambda x: x["send_level_db"], reverse=True) - - return { - "status": "success", - "mix_details": mix_info, - "active_sends": sends, - "message": f"Dettagli completi per il Mix {mix_number} recuperati." - } \ No newline at end of file + return {"status": "success", "mix_details": mix_info, "active_sends": sends, "message": f"Dettagli completi per il Mix {mix_number} recuperati."} \ No newline at end of file diff --git a/profiles/1552084582.json b/profiles/1552084582.json new file mode 100644 index 0000000..e2f0484 --- /dev/null +++ b/profiles/1552084582.json @@ -0,0 +1,17 @@ +{ + "user_id": 1552084582, + "username": "laissaantuness", + "role": "musicista", + "display_name": "Lai", + "channel_labels": [ + "Chitarra" + ], + "mix_labels": [ + "Aux 7" + ], + "setup_completed": true, + "created_at": 1762199748.9965794, + "created_at_str": "2025-11-03 19:55:48", + "last_updated": 1762199837.8484952, + "last_updated_str": "2025-11-03 19:57:17" +} \ No newline at end of file diff --git a/profiles/309700430.json b/profiles/309700430.json new file mode 100644 index 0000000..d1e657e --- /dev/null +++ b/profiles/309700430.json @@ -0,0 +1,13 @@ +{ + "user_id": 309700430, + "username": "Cicciomt7", + "role": "mixerista", + "display_name": "Frank", + "channel_labels": [], + "mix_labels": [], + "setup_completed": true, + "created_at": 1762198859.8032317, + "created_at_str": "2025-11-03 19:40:59", + "last_updated": 1762198880.1237595, + "last_updated_str": "2025-11-03 19:41:20" +} \ No newline at end of file diff --git a/profiles/392578198.json b/profiles/392578198.json new file mode 100644 index 0000000..604d436 --- /dev/null +++ b/profiles/392578198.json @@ -0,0 +1,17 @@ +{ + "user_id": 392578198, + "username": "Unknown", + "role": "cantante", + "display_name": "Rox", + "channel_labels": [ + "Vox1" + ], + "mix_labels": [ + "Aux2" + ], + "setup_completed": true, + "created_at": 1762199748.3117115, + "created_at_str": "2025-11-03 19:55:48", + "last_updated": 1762199831.8709695, + "last_updated_str": "2025-11-03 19:57:11" +} \ No newline at end of file diff --git a/profiles/436176546.json b/profiles/436176546.json new file mode 100644 index 0000000..6e2d3e0 --- /dev/null +++ b/profiles/436176546.json @@ -0,0 +1,17 @@ +{ + "user_id": 436176546, + "username": "Gloriushhh", + "role": "cantante", + "display_name": "Gloriush", + "channel_labels": [ + "VOX 2" + ], + "mix_labels": [ + "AUX 3" + ], + "setup_completed": true, + "created_at": 1762199750.435028, + "created_at_str": "2025-11-03 19:55:50", + "last_updated": 1762199825.8800879, + "last_updated_str": "2025-11-03 19:57:05" +} \ No newline at end of file diff --git a/profiles/4762864.json b/profiles/4762864.json index 63bd6dd..5ade06c 100644 --- a/profiles/4762864.json +++ b/profiles/4762864.json @@ -1,17 +1,21 @@ { "user_id": 4762864, "username": "unnikked", - "role": "cantante", + "role": "mixerista", "display_name": "Nicola", "channel_labels": [ - "Vox1" + "I canali della batteria (kick", + "drum", + "pan", + "tom", + "etc)" ], "mix_labels": [ - "Aux3" + "Aux batteria" ], "setup_completed": true, "created_at": 1762198047.365003, "created_at_str": "2025-11-03 20:27:27", - "last_updated": 1762198068.4479709, - "last_updated_str": "2025-11-03 20:27:48" + "last_updated": 1762799485.4542227, + "last_updated_str": "2025-11-10 19:31:25" } \ No newline at end of file diff --git a/profiles/540754896.json b/profiles/540754896.json new file mode 100644 index 0000000..207ea04 --- /dev/null +++ b/profiles/540754896.json @@ -0,0 +1,17 @@ +{ + "user_id": 540754896, + "username": "merymoon001", + "role": "musicista", + "display_name": "Moon", + "channel_labels": [ + "Tastiera" + ], + "mix_labels": [ + "aux 8" + ], + "setup_completed": true, + "created_at": 1762199744.617781, + "created_at_str": "2025-11-03 19:55:44", + "last_updated": 1762199821.1137702, + "last_updated_str": "2025-11-03 19:57:01" +} \ No newline at end of file