aggiunti meter

This commit is contained in:
Nick
2026-04-20 20:17:46 +02:00
parent 84c1f0da35
commit c2900bbb2e
10 changed files with 377 additions and 314 deletions
+3 -3
View File
@@ -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": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
{
"ip": "192.168.1.57",
"timestamp": 1776709037.7516325
}
+49
View File
@@ -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
}
+49 -125
View File
@@ -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()
# ==========================================
# ESECUZIONE AL CARICAMENTO DEL MODULO
# ==========================================
# Viene eseguito nel momento in cui mixer_controller.py fa "from config import *"
DEFAULT_HOST = get_mixer_ip()
+1 -1
View File
@@ -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:
+148 -91
View File
@@ -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}
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,6 +580,15 @@ class TF5MixerController:
# =========================================================================
def refresh_cache(self) -> dict:
# --- 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."}
try:
print("🔄 Aggiornamento cache completo in corso...")
channels_data, mixes_data, steinch_data = {}, {}, {}
fxrtn_data, dcas_data, matrices_data = {}, {}, {}
@@ -606,8 +604,16 @@ class TF5MixerController:
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)
# --- 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
}
# Stereo Input Channels
for ch in range(1, TF5_ST_INPUT_CHANNELS + 1):
@@ -619,7 +625,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# FX Return Channels
for ch in range(1, TF5_FX_RETURN_CHANNELS + 1):
@@ -628,7 +634,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# DCA Groups
for dca in range(1, TF5_DCA_GROUPS + 1):
@@ -637,7 +643,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# Mix Buses
for mix in range(1, TF5_MIX_BUSSES + 1):
@@ -646,7 +652,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# Matrix Buses
for mtrx in range(1, TF5_MATRIX_BUSSES + 1):
@@ -655,7 +661,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# Stereo Bus (max 2: L/R)
for st in range(1, TF5_STEREO_BUSSES + 1):
@@ -664,7 +670,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
# Mono Bus
name = self._parse_name(self._send_command("get MIXER:Current/Mono/Label/Name 0 0"))
@@ -678,7 +684,7 @@ class TF5MixerController:
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)
# time.sleep(0.01)
with self._cache_lock:
self._cache = {
@@ -700,6 +706,8 @@ class TF5MixerController:
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"))
@@ -855,8 +864,12 @@ class TF5MixerController:
"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 -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")
+30 -1
View File
@@ -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; }
</style>
</head>
@@ -137,6 +152,13 @@
{{ fader.name }}
</div>
<div v-if="activeTab === 'channel' && fader.ha_gain !== null"
class="ha-gain-badge"
:class="haGainClass(fader.ha_gain)"
:title="`HA Gain: +${fader.ha_gain} dB`">
+{{ fader.ha_gain }} dB
</div>
<button @click="toggleOn(fader.id, fader.on)"
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
:class="fader.on ? 'btn-on' : 'btn-off'">ON</button>
@@ -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');