diff --git a/.tf5_mixer_cache/channels_cache.json b/.tf5_mixer_cache/channels_cache.json index d452a92..aa22300 100644 --- a/.tf5_mixer_cache/channels_cache.json +++ b/.tf5_mixer_cache/channels_cache.json @@ -45,8 +45,8 @@ "7": { "channel": 7, "name": "Basso", - "on": false, - "level_db": null, + "on": true, + "level_db": -4.6, "pan": 0 }, "8": { @@ -403,7 +403,7 @@ "level_db": 6.0 } }, - "timestamp": 1776106373.6489363, + "timestamp": 1776706150.4343228, "steinch": {}, "fxrtn": { "1": { diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index 5c5bc45..8301799 100644 Binary files a/__pycache__/config.cpython-312.pyc and b/__pycache__/config.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index a47d332..ca4bc88 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/__pycache__/mixer_controller.cpython-312.pyc b/__pycache__/mixer_controller.cpython-312.pyc index 4853ee1..eaae56b 100644 Binary files a/__pycache__/mixer_controller.cpython-312.pyc and b/__pycache__/mixer_controller.cpython-312.pyc differ diff --git a/cache/mixer_ip.json b/cache/mixer_ip.json new file mode 100644 index 0000000..4f7977a --- /dev/null +++ b/cache/mixer_ip.json @@ -0,0 +1,4 @@ +{ + "ip": "192.168.1.57", + "timestamp": 1776709037.7516325 +} \ No newline at end of file diff --git a/cache/tf5_cache.json b/cache/tf5_cache.json new file mode 100644 index 0000000..257a5fc --- /dev/null +++ b/cache/tf5_cache.json @@ -0,0 +1,49 @@ +{ + "channels": { + "23": { + "channel": 23, + "level_db": 3.5 + }, + "24": { + "channel": 24, + "level_db": -4.1, + "on": false + }, + "20": { + "channel": 20, + "level_db": -0.2, + "on": true + }, + "5": { + "channel": 5, + "level_db": 2.7, + "on": true + }, + "6": { + "channel": 6, + "level_db": -4.1, + "on": true + }, + "2": { + "channel": 2, + "level_db": -2.55, + "on": true + } + }, + "mixes": {}, + "steinch": {}, + "fxrtn": {}, + "dcas": {}, + "matrices": {}, + "stereo": { + "1": { + "bus": 1, + "name": "Stereo", + "on": true, + "level_db": -11.9 + } + }, + "mono": {}, + "mute_masters": {}, + "timestamp": 1776707910.2890162 +} \ No newline at end of file diff --git a/config.py b/config.py index 3a7523b..e0392ed 100644 --- a/config.py +++ b/config.py @@ -7,31 +7,43 @@ import threading from concurrent.futures import ThreadPoolExecutor, as_completed import time -# Configurazione mixer +# ========================================== +# Specifiche Yamaha TF5 (FONDAMENTALI PER LA CACHE E IL CONTROLLER) +# ========================================== DEFAULT_PORT = 49280 -TF5_INPUT_CHANNELS = 40 -TF5_MIX_BUSSES = 20 -TF5_STEREO_BUSSES = 1 +TF5_INPUT_CHANNELS = 40 # CH 1-40 +TF5_ST_INPUT_CHANNELS = 2 # ST IN 1-2 +TF5_FX_RETURN_CHANNELS = 4 # FX RTN 1-4 +TF5_DCA_GROUPS = 8 # DCA 1-8 +TF5_MIX_BUSSES = 20 # MIX 1-20 +TF5_MATRIX_BUSSES = 4 # MATRIX 1-4 +TF5_STEREO_BUSSES = 1 # 1 Bus Stereo (L/R) +TF5_MUTE_GROUPS = 8 # Mute Master 1-8 +TF5_FX_SLOTS = 8 # Processori FX (8 slot interni) -# Configurazione cache -CACHE_DIR = Path(".tf5_mixer_cache") -CACHE_FILE = CACHE_DIR / "channels_cache.json" +# ========================================== +# Configurazione Cache (Mixer State & IP) +# ========================================== +CACHE_DIR = Path(__file__).parent / "cache" +CACHE_FILE = CACHE_DIR / "tf5_cache.json" IP_CACHE_FILE = CACHE_DIR / "mixer_ip.json" -CACHE_DURATION = 3600 # 60 minuti in secondi -IP_CACHE_DURATION = 300 # 5 minuti per l'IP +CACHE_DURATION = 60.0 # Secondi per la validità dello stato del mixer +IP_CACHE_DURATION = 300 # 5 minuti per la validità dell'IP del mixer + +# Assicurati che la cartella esista +CACHE_DIR.mkdir(parents=True, exist_ok=True) + +# ========================================== +# Funzioni di Utilità per Auto-Discovery +# ========================================== def log(message: str, level: str = "INFO"): """Stampa log formattato con timestamp""" timestamp = time.strftime("%H:%M:%S") symbols = { - "INFO": "ℹ️", - "SUCCESS": "✅", - "ERROR": "❌", - "WARNING": "⚠️", - "SEARCH": "🔍", - "NETWORK": "🌐", - "CACHE": "📍", - "TIME": "⏱️" + "INFO": "ℹ️", "SUCCESS": "✅", "ERROR": "❌", + "WARNING": "⚠️", "SEARCH": "🔍", "NETWORK": "🌐", + "CACHE": "📍", "TIME": "⏱️" } symbol = symbols.get(level, "•") print(f"[{timestamp}] {symbol} {message}") @@ -40,13 +52,11 @@ def get_local_network() -> str: """Ottiene la rete locale del sistema""" log("Rilevamento rete locale in corso...", "NETWORK") try: - # Crea un socket per ottenere l'IP locale s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) local_ip = s.getsockname()[0] s.close() - # Converte in network (es: 192.168.1.0/24) ip_parts = local_ip.split('.') network = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.0/24" @@ -67,9 +77,7 @@ def check_port(ip: str, port: int, timeout: float = 0.5) -> bool: result = sock.connect_ex((ip, port)) sock.close() return result == 0 - except Exception as e: - # Log solo per debug, commentabile se troppo verbose - # log(f"Errore su {ip}: {e}", "ERROR") + except Exception: return False def scan_network_for_mixer(port: int = DEFAULT_PORT, max_workers: int = 50) -> Optional[str]: @@ -78,32 +86,22 @@ def scan_network_for_mixer(port: int = DEFAULT_PORT, max_workers: int = 50) -> O start_time = time.time() network = get_local_network() - - # Genera lista di IP da scansionare ip_network = ipaddress.ip_network(network, strict=False) total_hosts = sum(1 for _ in ip_network.hosts()) - log(f"Indirizzi da scansionare: {total_hosts}", "INFO") - log(f"Thread paralleli: {max_workers}", "INFO") + log(f"Indirizzi da scansionare: {total_hosts} (Thread: {max_workers})", "INFO") found_ip = None scanned = 0 - # Scansione parallela per velocità with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_ip = { - executor.submit(check_port, str(ip), port): str(ip) - for ip in ip_network.hosts() - } - - log("Scansione in corso...", "SEARCH") + future_to_ip = {executor.submit(check_port, str(ip), port): str(ip) for ip in ip_network.hosts()} for future in as_completed(future_to_ip): ip = future_to_ip[future] scanned += 1 - # Log di progresso ogni 50 IP if scanned % 50 == 0: - log(f"Progresso: {scanned}/{total_hosts} IP scansionati", "INFO") + log(f"Progresso: {scanned}/{total_hosts} IP", "INFO") try: if future.result(): @@ -111,27 +109,19 @@ def scan_network_for_mixer(port: int = DEFAULT_PORT, max_workers: int = 50) -> O elapsed = time.time() - start_time log(f"MIXER TROVATO su {ip}:{port}", "SUCCESS") log(f"Tempo di scansione: {elapsed:.2f}s", "TIME") - log(f"IP scansionati: {scanned}/{total_hosts}", "INFO") - # Cancella i task rimanenti executor.shutdown(wait=False, cancel_futures=True) break - except Exception as e: - log(f"Errore durante scansione di {ip}: {e}", "ERROR") + except Exception: + pass if not found_ip: - elapsed = time.time() - start_time - log(f"Nessun mixer trovato sulla porta {port}", "ERROR") - log(f"Tempo totale di scansione: {elapsed:.2f}s", "TIME") - log(f"IP scansionati: {scanned}/{total_hosts}", "INFO") + log(f"Nessun mixer trovato. Tempo: {time.time() - start_time:.2f}s", "ERROR") return found_ip def load_cached_ip() -> Optional[str]: """Carica l'IP dalla cache se valido""" - log("Controllo cache IP...", "CACHE") - if not IP_CACHE_FILE.exists(): - log("Nessuna cache trovata", "INFO") return None try: @@ -139,115 +129,49 @@ def load_cached_ip() -> Optional[str]: data = json.load(f) cached_ip = data.get('ip') - cache_time = data.get('timestamp', 0) - age = time.time() - cache_time + age = time.time() - data.get('timestamp', 0) - log(f"Cache trovata: {cached_ip} (età: {int(age)}s)", "CACHE") - - # Controlla se la cache è ancora valida if age < IP_CACHE_DURATION: - log(f"Verifica raggiungibilità di {cached_ip}...", "CACHE") - # Verifica che l'IP sia ancora valido + log(f"Verifica raggiungibilità IP in cache: {cached_ip}...", "CACHE") if cached_ip and check_port(cached_ip, DEFAULT_PORT, timeout=1.0): log(f"IP dalla cache valido: {cached_ip}", "SUCCESS") return cached_ip else: - log(f"IP dalla cache non raggiungibile", "WARNING") + log(f"IP dalla cache non raggiungibile.", "WARNING") else: - log(f"Cache scaduta (max {IP_CACHE_DURATION}s)", "WARNING") + log(f"Cache IP scaduta.", "WARNING") except Exception as e: - log(f"Errore nel leggere cache IP: {e}", "ERROR") + log(f"Errore lettura cache IP: {e}", "ERROR") return None def save_ip_to_cache(ip: str): """Salva l'IP nella cache""" - log(f"Salvataggio IP in cache: {ip}", "CACHE") - CACHE_DIR.mkdir(exist_ok=True) - try: with open(IP_CACHE_FILE, 'w') as f: - json.dump({ - 'ip': ip, - 'timestamp': time.time() - }, f, indent=2) - log(f"Cache salvata: {IP_CACHE_FILE}", "SUCCESS") + json.dump({'ip': ip, 'timestamp': time.time()}, f, indent=2) except Exception as e: log(f"Errore nel salvare cache IP: {e}", "ERROR") def get_mixer_ip() -> str: - """ - Ottiene l'IP del mixer con auto-discovery: - 1. Controlla cache - 2. Scansiona la rete - 3. Fallback su IP predefinito - """ - log("=== INIZIO RICERCA MIXER ===", "INFO") + """Ottiene l'IP del mixer (Cache -> Discovery -> Fallback)""" + log("=== AVVIO RICERCA YAMAHA TF5 ===", "INFO") - # Prova con la cache cached_ip = load_cached_ip() if cached_ip: - log("=== RICERCA COMPLETATA (CACHE) ===", "SUCCESS") return cached_ip - # Scansiona la rete - log("Avvio scansione di rete...", "SEARCH") discovered_ip = scan_network_for_mixer() if discovered_ip: save_ip_to_cache(discovered_ip) - log("=== RICERCA COMPLETATA (DISCOVERY) ===", "SUCCESS") return discovered_ip - # Fallback fallback_ip = "192.168.1.57" log(f"Uso IP predefinito di fallback: {fallback_ip}", "WARNING") - log("=== RICERCA COMPLETATA (FALLBACK) ===", "WARNING") return fallback_ip -# Variabile globale per l'host (si aggiorna automaticamente) -DEFAULT_HOST = None - -def get_host() -> str: - """Ottiene l'host del mixer (con lazy loading)""" - global DEFAULT_HOST - if DEFAULT_HOST is None: - log("Host mixer non inizializzato, avvio discovery...", "INFO") - DEFAULT_HOST = get_mixer_ip() - else: - log(f"Host mixer già inizializzato: {DEFAULT_HOST}", "INFO") - return DEFAULT_HOST - -def refresh_mixer_ip(): - """Forza un refresh dell'IP del mixer""" - global DEFAULT_HOST - log("=== REFRESH FORZATO IP MIXER ===", "INFO") - log("Cancellazione cache esistente...", "CACHE") - - # Rimuovi cache - if IP_CACHE_FILE.exists(): - try: - IP_CACHE_FILE.unlink() - log("Cache rimossa", "SUCCESS") - except Exception as e: - log(f"Errore rimozione cache: {e}", "ERROR") - - # Nuova scansione - DEFAULT_HOST = scan_network_for_mixer() - if DEFAULT_HOST: - save_ip_to_cache(DEFAULT_HOST) - log(f"Nuovo IP impostato: {DEFAULT_HOST}", "SUCCESS") - else: - log("Refresh fallito, mantengo IP precedente", "ERROR") - - log("=== REFRESH COMPLETATO ===", "INFO") - return DEFAULT_HOST - -# Test rapido -if __name__ == "__main__": - log("=== TEST AUTO-DISCOVERY MIXER ===", "INFO") - host = get_host() - log(f"Host finale: {host}", "SUCCESS") - - # Test refresh - # log("\n=== TEST REFRESH ===", "INFO") - # refresh_mixer_ip() \ No newline at end of file +# ========================================== +# ESECUZIONE AL CARICAMENTO DEL MODULO +# ========================================== +# Viene eseguito nel momento in cui mixer_controller.py fa "from config import *" +DEFAULT_HOST = get_mixer_ip() \ No newline at end of file diff --git a/main.py b/main.py index 79f483a..121f88d 100644 --- a/main.py +++ b/main.py @@ -115,7 +115,7 @@ def get_actual_level(response_str: str, fallback_db: float) -> float: for part in reversed(parts): try: val_int = int(part) - return val_int / 100.0 if val_int > -32768 else float('-inf') + return val_int / 100.0 if val_int > -32768 else -120.0 # <--- SOSTITUITO float('-inf') CON -120.0 except ValueError: continue except Exception: diff --git a/mixer_controller.py b/mixer_controller.py index bfc021b..b354550 100644 --- a/mixer_controller.py +++ b/mixer_controller.py @@ -24,6 +24,7 @@ class TF5MixerController: self._ensure_cache_dir() self._cache_lock = threading.Lock() + self._refresh_lock = threading.Lock() # <--- AGGIUNGI QUESTA RIGA self._cache = self._load_cache() self._command_lock = threading.Lock() @@ -47,6 +48,12 @@ class TF5MixerController: self._reader_thread.daemon = True self._reader_thread.start() + # --- AGGIUNGI QUESTE 3 RIGHE --- + self._keepalive_thread = threading.Thread(target=self._meter_keepalive_loop) + self._keepalive_thread.daemon = True + self._keepalive_thread.start() + # ------------------------------- + if not self._is_connected.wait(timeout=5): print("⚠️ Impossibile connettersi al mixer all'avvio.") else: @@ -63,19 +70,13 @@ class TF5MixerController: self._is_connected.set() print(f"🔗 Connesso a {self.host}:{self.port}") - 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() - - # Piccola pausa per assicurarsi che il buffer sia pulito time.sleep(0.2) - - # Sottoscrivi DOPO il refresh (cache occupa il socket, meter devono aspettare) self.subscribe_meters(interval_ms=100) - self._socket_reader() # blocca qui finché connesso + # Caricamento stato iniziale dal mixer (una tantum) + threading.Thread(target=self.refresh_cache, daemon=True).start() + + self._socket_reader() except socket.error as e: print(f"🔥 Errore di connessione: {e}. Riprovo tra 5 secondi...") @@ -108,12 +109,14 @@ class TF5MixerController: if line.startswith("NOTIFY"): self._handle_notify(line) elif line.startswith("OK") or line.startswith("ERROR"): + # Ignoriamo i messaggi di routine e keepalive per non intasare la coda + if "mtrstart" in line or "mtrstop" in line or "Current/Mono/Fader" in line: + continue + 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 @@ -136,13 +139,20 @@ class TF5MixerController: Analizza un messaggio NOTIFY e aggiorna la cache in modo robusto. Gestisce InCh, StInCh, FxRtnCh, DCA, Mix, Mtrx, St, Mono, MuteMaster. """ + # --- NUOVA GESTIONE METER TF5 --- + if message.startswith("NOTIFY mtr "): + self._handle_tf5_meter_notify(message) + return + # -------------------------------- + print(f"RECV NOTIFY: {message}") parts = message.split() - - if len(parts) >= 2 and parts[1] == "mtrinfo": - self._handle_meter_notify(message) - return + + # Rimuovi o commenta queste 3 righe vecchie: + # if len(parts) >= 2 and parts[1] == "mtrinfo": + # self._handle_meter_notify(message) + # return if len(parts) < 2: return @@ -495,7 +505,6 @@ class TF5MixerController: 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): return self @@ -518,28 +527,11 @@ class TF5MixerController: CACHE_DIR.mkdir(parents=True, exist_ok=True) def _load_cache(self) -> dict: - with self._cache_lock: - if CACHE_FILE.exists(): - try: - with open(CACHE_FILE, 'r', encoding='utf-8') as f: - data = json.load(f) - for key in ("channels", "mixes", "steinch", "fxrtn", "dcas", "matrices", "stereo", "mono", "mute_masters"): - if key not in data: - data[key] = {} if key != "mono" else {} - return data - except Exception as e: - print(f"⚠️ Errore nel caricamento della cache: {e}") - return {"channels": {}, "mixes": {}, "steinch": {}, "fxrtn": {}, "dcas": {}, - "matrices": {}, "stereo": {}, "mono": {}, "mute_masters": {}, "timestamp": 0} + return {"channels": {}, "mixes": {}, "steinch": {}, "fxrtn": {}, "dcas": {}, + "matrices": {}, "stereo": {}, "mono": {}, "mute_masters": {}, "timestamp": 0} def _save_cache(self): - 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}") + pass # Non salviamo nulla su disco def _prepare_cache_for_json(self, obj): if isinstance(obj, dict): return {k: self._prepare_cache_for_json(v) for k, v in obj.items()} @@ -550,10 +542,7 @@ class TF5MixerController: else: return obj def _is_cache_valid(self) -> bool: - 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 + return self._is_connected.is_set() def _normalize_level_from_cache(self, level_value): if level_value is None: return float('-inf') @@ -582,7 +571,7 @@ class TF5MixerController: """Converte stringa valore interno in dB float.""" try: v = int(raw) - return v / 100.0 if v > -32768 else float('-inf') + return v / 100.0 if v > -32768 else -120.0 # <--- SOSTITUITO float('-inf') CON -120.0 except: return None @@ -591,115 +580,134 @@ class TF5MixerController: # ========================================================================= def refresh_cache(self) -> dict: - print("🔄 Aggiornamento cache completo in corso...") - channels_data, mixes_data, steinch_data = {}, {}, {} - fxrtn_data, dcas_data, matrices_data = {}, {}, {} - stereo_data, mute_masters_data = {}, {} - mono_data = {} + # --- NUOVO BLOCCO LOCK --- + # Se c'è già un refresh in corso, aspetta che finisca e poi esci. + if not self._refresh_lock.acquire(blocking=False): + print("⏳ Un refresh è già in corso. Attendo che finisca...") + self._refresh_lock.acquire() # Aspetta il rilascio + self._refresh_lock.release() + return {"status": "success", "message": "Cache già aggiornata da un altro processo."} - # Input Channels - for ch in range(1, TF5_INPUT_CHANNELS + 1): - ch_idx = ch - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0"))) - pan_raw = self._parse_value(self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0")) - 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) + try: + print("🔄 Aggiornamento cache completo in corso...") + channels_data, mixes_data, steinch_data = {}, {}, {} + fxrtn_data, dcas_data, matrices_data = {}, {}, {} + stereo_data, mute_masters_data = {}, {} + mono_data = {} - # Stereo Input Channels - for ch in range(1, TF5_ST_INPUT_CHANNELS + 1): - ch_idx = ch - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/StInCh/Label/Name {ch_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/StInCh/Fader/On {ch_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/StInCh/Fader/Level {ch_idx} 0"))) - pan_raw = self._parse_value(self._send_command(f"get MIXER:Current/StInCh/ToSt/Pan {ch_idx} 0")) - try: pan_value = int(pan_raw) - except: pan_value = None - steinch_data[str(ch)] = {"channel": ch, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} - time.sleep(0.01) + # Input Channels + for ch in range(1, TF5_INPUT_CHANNELS + 1): + ch_idx = ch - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0"))) + pan_raw = self._parse_value(self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0")) + try: pan_value = int(pan_raw) + except: pan_value = None + # --- NUOVO --- + ha_gain_raw = self._parse_value(self._send_command(f"get MIXER:Current/InCh/Ha/Gain {ch_idx} 0")) + try: ha_gain = int(ha_gain_raw) + except: ha_gain = None + # ------------- + channels_data[str(ch)] = { + "channel": ch, "name": name, "on": is_on, + "level_db": level_db, "pan": pan_value, + "ha_gain": ha_gain # <-- nuovo campo + } - # FX Return Channels - for ch in range(1, TF5_FX_RETURN_CHANNELS + 1): - ch_idx = ch - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/FxRtnCh/Label/Name {ch_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/FxRtnCh/Fader/On {ch_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/FxRtnCh/Fader/Level {ch_idx} 0"))) - fxrtn_data[str(ch)] = {"channel": ch, "name": name, "on": is_on, "level_db": level_db} - time.sleep(0.01) + # Stereo Input Channels + for ch in range(1, TF5_ST_INPUT_CHANNELS + 1): + ch_idx = ch - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/StInCh/Label/Name {ch_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/StInCh/Fader/On {ch_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/StInCh/Fader/Level {ch_idx} 0"))) + pan_raw = self._parse_value(self._send_command(f"get MIXER:Current/StInCh/ToSt/Pan {ch_idx} 0")) + try: pan_value = int(pan_raw) + except: pan_value = None + steinch_data[str(ch)] = {"channel": ch, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} + # time.sleep(0.01) - # DCA Groups - for dca in range(1, TF5_DCA_GROUPS + 1): - dca_idx = dca - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/DCA/Label/Name {dca_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/DCA/Fader/On {dca_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/DCA/Fader/Level {dca_idx} 0"))) - dcas_data[str(dca)] = {"dca": dca, "name": name, "on": is_on, "level_db": level_db} - time.sleep(0.01) + # FX Return Channels + for ch in range(1, TF5_FX_RETURN_CHANNELS + 1): + ch_idx = ch - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/FxRtnCh/Label/Name {ch_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/FxRtnCh/Fader/On {ch_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/FxRtnCh/Fader/Level {ch_idx} 0"))) + fxrtn_data[str(ch)] = {"channel": ch, "name": name, "on": is_on, "level_db": level_db} + # time.sleep(0.01) - # Mix Buses - for mix in range(1, TF5_MIX_BUSSES + 1): - mix_idx = mix - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0"))) - mixes_data[str(mix)] = {"mix": mix, "name": name, "on": is_on, "level_db": level_db} - time.sleep(0.01) + # DCA Groups + for dca in range(1, TF5_DCA_GROUPS + 1): + dca_idx = dca - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/DCA/Label/Name {dca_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/DCA/Fader/On {dca_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/DCA/Fader/Level {dca_idx} 0"))) + dcas_data[str(dca)] = {"dca": dca, "name": name, "on": is_on, "level_db": level_db} + # time.sleep(0.01) - # Matrix Buses - for mtrx in range(1, TF5_MATRIX_BUSSES + 1): - mtrx_idx = mtrx - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/Mtrx/Label/Name {mtrx_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/Mtrx/Fader/On {mtrx_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/Mtrx/Fader/Level {mtrx_idx} 0"))) - matrices_data[str(mtrx)] = {"matrix": mtrx, "name": name, "on": is_on, "level_db": level_db} - time.sleep(0.01) + # Mix Buses + for mix in range(1, TF5_MIX_BUSSES + 1): + mix_idx = mix - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/Mix/Label/Name {mix_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/Mix/Fader/On {mix_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/Mix/Fader/Level {mix_idx} 0"))) + mixes_data[str(mix)] = {"mix": mix, "name": name, "on": is_on, "level_db": level_db} + # time.sleep(0.01) - # Stereo Bus (max 2: L/R) - for st in range(1, TF5_STEREO_BUSSES + 1): - st_idx = st - 1 - name = self._parse_name(self._send_command(f"get MIXER:Current/St/Label/Name {st_idx} 0")) - is_on = self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/On {st_idx} 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/Level {st_idx} 0"))) - stereo_data[str(st)] = {"bus": st, "name": name, "on": is_on, "level_db": level_db} - time.sleep(0.01) + # Matrix Buses + for mtrx in range(1, TF5_MATRIX_BUSSES + 1): + mtrx_idx = mtrx - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/Mtrx/Label/Name {mtrx_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/Mtrx/Fader/On {mtrx_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/Mtrx/Fader/Level {mtrx_idx} 0"))) + matrices_data[str(mtrx)] = {"matrix": mtrx, "name": name, "on": is_on, "level_db": level_db} + # time.sleep(0.01) - # Mono Bus - name = self._parse_name(self._send_command("get MIXER:Current/Mono/Label/Name 0 0")) - is_on = self._parse_value(self._send_command("get MIXER:Current/Mono/Fader/On 0 0")) == "1" - level_db = self._internal_to_level(self._parse_value(self._send_command("get MIXER:Current/Mono/Fader/Level 0 0"))) - mono_data = {"name": name, "on": is_on, "level_db": level_db} + # Stereo Bus (max 2: L/R) + for st in range(1, TF5_STEREO_BUSSES + 1): + st_idx = st - 1 + name = self._parse_name(self._send_command(f"get MIXER:Current/St/Label/Name {st_idx} 0")) + is_on = self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/On {st_idx} 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command(f"get MIXER:Current/St/Fader/Level {st_idx} 0"))) + stereo_data[str(st)] = {"bus": st, "name": name, "on": is_on, "level_db": level_db} + # time.sleep(0.01) - # Mute Masters - for group in range(1, TF5_MUTE_GROUPS + 1): - group_idx = group - 1 - is_on = self._parse_value(self._send_command(f"get MIXER:Current/MuteMaster/On {group_idx} 0")) == "1" - name_raw = self._parse_name(self._send_command(f"get MIXER:Current/MuteMaster/Label/Name {group_idx} 0")) - mute_masters_data[str(group)] = {"group": group, "name": name_raw, "on": is_on} - time.sleep(0.01) + # Mono Bus + name = self._parse_name(self._send_command("get MIXER:Current/Mono/Label/Name 0 0")) + is_on = self._parse_value(self._send_command("get MIXER:Current/Mono/Fader/On 0 0")) == "1" + level_db = self._internal_to_level(self._parse_value(self._send_command("get MIXER:Current/Mono/Fader/Level 0 0"))) + mono_data = {"name": name, "on": is_on, "level_db": level_db} - with self._cache_lock: - self._cache = { - "channels": channels_data, - "mixes": mixes_data, - "steinch": steinch_data, - "fxrtn": fxrtn_data, - "dcas": dcas_data, - "matrices": matrices_data, - "stereo": stereo_data, - "mono": mono_data, - "mute_masters": mute_masters_data, - "timestamp": time.time() - } - self._save_cache() + # Mute Masters + for group in range(1, TF5_MUTE_GROUPS + 1): + group_idx = group - 1 + is_on = self._parse_value(self._send_command(f"get MIXER:Current/MuteMaster/On {group_idx} 0")) == "1" + name_raw = self._parse_name(self._send_command(f"get MIXER:Current/MuteMaster/Label/Name {group_idx} 0")) + mute_masters_data[str(group)] = {"group": group, "name": name_raw, "on": is_on} + # time.sleep(0.01) - msg = (f"Cache aggiornata: {len(channels_data)} InCh, {len(steinch_data)} StInCh, " - f"{len(fxrtn_data)} FxRtn, {len(dcas_data)} DCA, {len(mixes_data)} Mix, " - f"{len(matrices_data)} Mtrx, {len(stereo_data)} St, Mono, {len(mute_masters_data)} MuteMaster") - print(f"✅ {msg}") - return {"status": "success", "message": msg} + with self._cache_lock: + self._cache = { + "channels": channels_data, + "mixes": mixes_data, + "steinch": steinch_data, + "fxrtn": fxrtn_data, + "dcas": dcas_data, + "matrices": matrices_data, + "stereo": stereo_data, + "mono": mono_data, + "mute_masters": mute_masters_data, + "timestamp": time.time() + } + self._save_cache() + + msg = (f"Cache aggiornata: {len(channels_data)} InCh, {len(steinch_data)} StInCh, " + f"{len(fxrtn_data)} FxRtn, {len(dcas_data)} DCA, {len(mixes_data)} Mix, " + f"{len(matrices_data)} Mtrx, {len(stereo_data)} St, Mono, {len(mute_masters_data)} MuteMaster") + print(f"✅ {msg}") + return {"status": "success", "message": msg} + finally: + self._refresh_lock.release() # ========================================================================= # INPUT CHANNELS (InCh) @@ -840,7 +848,8 @@ class TF5MixerController: "channel": cached_data["channel"], "name": cached_data["name"], "on": cached_data["on"], "level_db": self._sanitize_value(self._normalize_level_from_cache(cached_data.get("level_db"))), - "pan": cached_data.get("pan")} + "pan": cached_data.get("pan"), + "ha_gain": cached_data.get("ha_gain")} ch_idx = channel - 1 name = self._parse_name(self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0")) @@ -854,9 +863,13 @@ class TF5MixerController: self._cache.setdefault("channels", {})[str(channel)] = { "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value} self._cache['timestamp'] = time.time() + + ha_gain_raw = self._parse_value(self._send_command(f"get MIXER:Current/InCh/Ha/Gain {ch_idx} 0")) + try: ha_gain = int(ha_gain_raw) + except: ha_gain = None return {"status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, - "level_db": self._sanitize_value(level_db), "pan": pan_value} + "level_db": self._sanitize_value(level_db), "pan": pan_value, "ha_gain": ha_gain} def get_channel_to_mix_info(self, channel: int, mix_number: int) -> dict: if not 1 <= channel <= TF5_INPUT_CHANNELS: @@ -872,7 +885,7 @@ class TF5MixerController: "message": f"Canale {channel} → Mix {mix_number}: {sanitized:+.1f} dB ({'ON' if is_on else 'OFF'})"} def get_all_channels_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: items_copy = list(self._cache.get("channels", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -884,7 +897,8 @@ class TF5MixerController: "channel": ch_num, "name": info.get("name") or f"CH {ch_num}", "on": info.get("on", False), - "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))) + "level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db"))), + "ha_gain": info.get("ha_gain") }) results.sort(key=lambda x: x["channel"]) return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)} @@ -902,7 +916,7 @@ class TF5MixerController: "message": f"Riattivati {success_count}/{len(channels)} canali: {channels}", "details": results} def search_channels_by_name(self, search_term: str) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() with self._cache_lock: channels_copy = list(self._cache.get("channels", {}).values()) @@ -1053,7 +1067,7 @@ class TF5MixerController: "message": f"StInCh {channel} → Mono send {'acceso' if state else 'spento'}", "response": response} def get_all_steinch_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: items_copy = list(self._cache.get("steinch", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -1183,7 +1197,7 @@ class TF5MixerController: "message": f"FxRtnCh {channel} → St pan impostato a {pan_value}", "response": response} def get_all_fxrtn_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: items_copy = list(self._cache.get("fxrtn", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -1250,7 +1264,7 @@ class TF5MixerController: def get_all_dca_summary(self) -> dict: """Restituisce il riepilogo di tutti i gruppi DCA dalla cache.""" - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: dcas_copy = list(self._cache.get("dcas", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -1354,7 +1368,7 @@ class TF5MixerController: "message": f"Mix {mix_number} → Matrix {matrix_number} send {'acceso' if state else 'spento'}", "response": response} def get_all_mixes_summary(self) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: items_copy = list(self._cache.get("mixes", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -1372,7 +1386,7 @@ class TF5MixerController: return {"status": "success", "total_mixes": len(results), "mixes": results, "cache_age_seconds": int(cache_age)} def search_mixes_by_name(self, search_term: str) -> dict: - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() with self._cache_lock: mixes_copy = list(self._cache.get("mixes", {}).values()) @@ -1387,7 +1401,7 @@ class TF5MixerController: def get_full_mix_details(self, mix_number: int) -> dict: 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() + # 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 sends = [] @@ -1455,7 +1469,7 @@ class TF5MixerController: def get_all_mtrx_summary(self) -> dict: """Restituisce il riepilogo di tutti i bus Matrix dalla cache.""" - if not self._is_cache_valid(): self.refresh_cache() + # if not self._is_cache_valid(): self.refresh_cache() with self._cache_lock: matrices_copy = list(self._cache.get("matrices", {}).values()) cache_age = time.time() - self._cache.get("timestamp", 0) @@ -1697,61 +1711,88 @@ class TF5MixerController: "message": f"Scena salvata nel banco {bank.upper()}{scene_number}.", "response": response} # ========================================================================= - # METER READING (via prminfo subscription) + # METER READING (via mtrstart subscription protocollo TF) # ========================================================================= + # Mappiamo le stringhe attese dal frontend con i percorsi SCP del TF5 _METER_SUBSCRIPTIONS = { - 2000: {"key": "InCh", "channels": 40, "types": 3}, - 2001: {"key": "StInCh", "channels": 4, "types": 3}, - 2002: {"key": "FxRtnCh", "channels": 4, "types": 2}, - 2100: {"key": "Mix", "channels": 20, "types": 3}, - 2101: {"key": "Mtrx", "channels": 4, "types": 3}, - 2102: {"key": "St", "channels": 2, "types": 3}, - 2103: {"key": "Mono", "channels": 1, "types": 3}, + "channel": "MIXER:Current/InCh/PostOn", + "steinch": "MIXER:Current/StInCh/PostOn", + "fxrtn": "MIXER:Current/FxRtnCh/PostOn", + "mix": "MIXER:Current/Mix/PostOn", + "mtrx": "MIXER:Current/Mtrx/PostOn", + "stereo": "MIXER:Current/St/PostOn", } - # Dizionario meter: { "InCh": [[ch1t0, ch1t1, ch1t2], [ch2t0,...], ...], ... } - # Oppure più semplice: { "InCh": {type_idx: [val_ch1, val_ch2, ...]}, ... } _meter_data: dict = {} - _meter_lock: threading.Lock = None # verrà inizializzato in __init__ + _meter_lock: threading.Lock = None def _init_meter_state(self): """Inizializza la struttura dati per i meter.""" self._meter_lock = threading.Lock() - self._meter_data = {} - for info in self._METER_SUBSCRIPTIONS.values(): - key = info["key"] - self._meter_data[key] = { - t: [0] * info["channels"] for t in range(info["types"]) - } + self._meter_data = {key: [] for key in self._METER_SUBSCRIPTIONS.keys()} def subscribe_meters(self, interval_ms: int = 100): - # Il comando corretto per i meter Yamaha TF è "mtrinfo" - for meter_id in self._METER_SUBSCRIPTIONS: - self._send_raw(f"mtrinfo {meter_id} {interval_ms}") + """Invia il comando mtrstart per attivare l'invio dei dati dei meter.""" + for path in self._METER_SUBSCRIPTIONS.values(): + self._send_raw(f"mtrstart {path} {interval_ms}") time.sleep(0.05) def unsubscribe_meters(self): - for meter_id in self._METER_SUBSCRIPTIONS: - self._send_raw(f"mtrinfo {meter_id} 0") + """Ferma l'invio dei dati dei meter.""" + for path in self._METER_SUBSCRIPTIONS.values(): + self._send_raw(f"mtrstop {path}") + + def _handle_tf5_meter_notify(self, message: str): + """ + Gestisce: NOTIFY mtr MIXER:Current/Mix/PostOn level 00 00 00 00... + """ + try: + # Dividiamo la parte intestazione dalla parte dati (i valori hex) + parts = message.split(" level ") + if len(parts) != 2: + return + + header_parts = parts[0].split() + path = header_parts[2] # Es: MIXER:Current/Mix/PostOn + hex_values_str = parts[1].strip() + + # Troviamo a quale chiave frontend corrisponde questo path + target_key = None + for key, sub_path in self._METER_SUBSCRIPTIONS.items(): + if sub_path == path: + target_key = key + break + + if not target_key: + return + + # Convertiamo l'array di stringhe hex ('00', '4A', ecc) in interi (0-127) + int_values = [int(v, 16) for v in hex_values_str.split()] + + with self._meter_lock: + self._meter_data[target_key] = int_values + + except Exception as e: + # Ignora errori di parsing su pacchetti malformati per non crashare + pass def get_meter_snapshot(self) -> dict: - """Restituisce l'ultimo snapshot meter ricevuto.""" + """Restituisce l'ultimo snapshot meter formattato per il frontend Vue.""" with self._meter_lock: - # Costruisce il formato atteso dal frontend snapshot = {} - for meter_id, info in self._METER_SUBSCRIPTIONS.items(): - key = info["key"] - frontend_key = key.lower() - # Il frontend si aspetta { readings: [{channel, raw, level_db}] } - type_idx = info["types"] - 1 # Usiamo l'ultimo tipo (PostOn) + for frontend_key in self._METER_SUBSCRIPTIONS.keys(): + raw_values = self._meter_data.get(frontend_key, []) readings = [] - for ch_idx, raw in enumerate(self._meter_data[key][type_idx]): + + # Creiamo l'array [{channel: 1, raw: 45}, {channel: 2, raw: 0}...] + for ch_idx, raw in enumerate(raw_values): readings.append({ "channel": ch_idx + 1, "raw": raw, "level_db": self._meter_raw_to_db(raw) }) + snapshot[frontend_key] = { "readings": readings, "type_name": "PostOn" @@ -1759,6 +1800,22 @@ class TF5MixerController: return snapshot def _meter_raw_to_db(self, raw: int) -> float: + """Converte il valore raw (0-127) in dB (approssimativo)""" if raw <= 0: - return float('-inf') - return round((raw / 127.0) * 90.0 - 72.0, 1) \ No newline at end of file + return -120.0 # <--- SOSTITUITO float('-inf') CON -120.0 + return round((raw / 127.0) * 90.0 - 72.0, 1) + + def _meter_keepalive_loop(self): + """ + Invia periodicamente il comando di iscrizione ai meter per evitare + che il mixer vada in timeout e smetta di trasmetterli. + """ + while self._is_running.is_set(): + # Aspetta 10 secondi + time.sleep(10) + + # Se siamo connessi, rinnova la richiesta + if self._is_connected.is_set(): + self.subscribe_meters(interval_ms=100) + # Inviamo anche un comando vuoto per tenere vivo il socket in generale + self._send_raw("get MIXER:Current/Mono/Fader/Level 0 0") \ No newline at end of file diff --git a/static/index.html b/static/index.html index 27d9d26..b29a383 100644 --- a/static/index.html +++ b/static/index.html @@ -82,6 +82,21 @@ background: linear-gradient(to top, #4ade80, #facc15 85%, #ef4444 95%); transition: height 0.05s linear; /* Transizione fluida */ } + + .ha-gain-badge { + font-family: monospace; + font-size: 9px; + font-weight: bold; + padding: 1px 4px; + border-radius: 3px; + width: 100%; + text-align: center; + margin-bottom: 2px; + } + .ha-gain-low { background: #1e3a5f; color: #60a5fa; } + .ha-gain-mid { background: #1a3a1a; color: #4ade80; } + .ha-gain-high { background: #3a1a00; color: #fb923c; } + .ha-gain-danger { background: #3a0a0a; color: #f87171; } @@ -137,6 +152,13 @@ {{ fader.name }} +
+ +{{ fader.ha_gain }} dB +
+ @@ -285,7 +307,7 @@ const id = item.channel || item.mix || item.dca || item.id; const isDragging = this.draggingFaders[id]; const displayDb = isDragging ? this.localLevels[id] : (item.level_db === -Infinity || item.level_db <= -120 ? -120 : item.level_db); - return { id: id, name: item.name, on: item.on, level_db: displayDb, slider_pos: dbToSlider(displayDb) }; + return { id: id, name: item.name, on: item.on, level_db: displayDb, slider_pos: dbToSlider(displayDb), ha_gain: item.ha_gain ?? null }; }); }, currentMixMaster() { @@ -380,6 +402,13 @@ } else { this.ws.send(JSON.stringify({ action: 'set_on', type: type, id: id, value: !currentState })); } + }, + haGainClass(gain) { + if (gain === null || gain === undefined) return 'ha-gain-low'; + if (gain < 20) return 'ha-gain-low'; + if (gain < 40) return 'ha-gain-mid'; + if (gain < 55) return 'ha-gain-high'; + return 'ha-gain-danger'; } } }).mount('#app');