From c2900bbb2e3284ca03903cb1e441446aacb42906 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 20 Apr 2026 20:17:46 +0200 Subject: [PATCH] aggiunti meter --- .tf5_mixer_cache/channels_cache.json | 6 +- __pycache__/config.cpython-312.pyc | Bin 11179 -> 8444 bytes __pycache__/main.cpython-312.pyc | Bin 15590 -> 15566 bytes __pycache__/mixer_controller.cpython-312.pyc | Bin 117398 -> 117562 bytes cache/mixer_ip.json | 4 + cache/tf5_cache.json | 49 +++ config.py | 174 +++----- main.py | 2 +- mixer_controller.py | 425 +++++++++++-------- static/index.html | 31 +- 10 files changed, 377 insertions(+), 314 deletions(-) create mode 100644 cache/mixer_ip.json create mode 100644 cache/tf5_cache.json 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 5c5bc4511865e34501f5938786198e844d7df36f..83017990db042d5c28013ea084ad44cbf6934dcb 100644 GIT binary patch delta 4250 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 literal 11179 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 diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index a47d3328f3b01959a9a697b5fe86e20f2f21f8e3..ca4bc884ab8b9099be92ff6b0839314cc16c3bc4 100644 GIT binary patch delta 352 zcmaD>d9IT8G%qg~0}x20Kg+b($ooNx@!I5nQc>*E3=Fl*oD7o{&2W%|5k&HEI!dq}6a|ZjGlJL>%ph@TPDc&qgX)Zq8k@aj zcMFKO0S&BTcJ@#x@&dAocz}c^OHuu18NHoMj7^iT8H6!*Pu4Y5;4TNs++t13$xkeq z9A;R~SUvfi;Tz#vkSKRaVo`c(Nqk9VL26Om)UmXAd delta 393 zcmX?C`K*%nG%qg~0}$ky-pv%*$ooNx@x|nSQc;X5lOv_|UAam)L6RVl!n~Rh!b)ah zWC&(pVc=w_VGL$aW+(w00FqN-C}L)0h-Bz7EoZ1;j$|lj1j_PE-Yu=GUB#lCnU|)? zev7rZq$slhNEPH{mfYefN-aw*Do#zg#hjT}a*H)BCqHpAzf2fc4bZp(ATDm1oG;_X zd4%;q+F{;H0=|=X$vBv)Fszq%6lFTd$Ka=28@n|n+;@l3y8M^4Xt8!_E0GD0SglY8v#cr`V zIy<`tJKkao4si7ganw{S3J0k#0}&Mn+a diff --git a/__pycache__/mixer_controller.cpython-312.pyc b/__pycache__/mixer_controller.cpython-312.pyc index 4853ee12044a8273ae551813b4091739985d7021..eaae56b2213640844739ffd12db001f4534b8fda 100644 GIT binary patch delta 30325 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 WP7znWNeUwxlSu>lm}6h;f&T-c=kxXe delta 29684 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 F{{f?|)P(>5 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');